Force last owner of a room to pass ownership when leaving (#5094)

* Move `ChangeRoles*` classes to their own module so they can be shared

* Hook the change roles screen to the leave room action, add confirmation dialogs

* Use enum instead of sealed interface for `ChangeRoomMemberRolesListType`

* Try to improve communications between nodes

* refactor (leave room) : makes sure to expose only necessary code from api module

* Add `:libraries:previewutils` module to share some test fixtures used for UI previews

* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
Co-authored-by: ganfra <francoisg@matrix.org>
This commit is contained in:
Jorge Martin Espinosa 2025-08-05 17:24:14 +02:00 committed by GitHub
parent a87bbdd91c
commit 955263bee1
112 changed files with 1337 additions and 513 deletions

View file

@ -318,6 +318,7 @@ licensee {
allow("MIT") allow("MIT")
allow("BSD-2-Clause") allow("BSD-2-Clause")
allow("BSD-3-Clause") allow("BSD-3-Clause")
allow("EPL-1.0")
allowUrl("https://opensource.org/licenses/MIT") allowUrl("https://opensource.org/licenses/MIT")
allowUrl("https://developer.android.com/studio/terms.html") allowUrl("https://developer.android.com/studio/terms.html")
allowUrl("https://www.zetetic.net/sqlcipher/license/") allowUrl("https://www.zetetic.net/sqlcipher/license/")

View file

@ -14,6 +14,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.MembershipCha
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -28,6 +29,7 @@ class LoggedInEventProcessor @Inject constructor(
fun observeEvents(coroutineScope: CoroutineScope) { fun observeEvents(coroutineScope: CoroutineScope) {
observingJob = roomMembershipObserver.updates observingJob = roomMembershipObserver.updates
.filter { !it.isUserInRoom } .filter { !it.isUserInRoom }
.distinctUntilChanged()
.onEach { .onEach {
when (it.change) { when (it.change) {
MembershipChange.LEFT -> displayMessage(CommonStrings.common_current_user_left_room) MembershipChange.LEFT -> displayMessage(CommonStrings.common_current_user_left_room)

View file

@ -0,0 +1,27 @@
import extension.setupAnvil
/*
* 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.
*/
plugins {
id("io.element.android-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.changeroommemberroles.api"
}
setupAnvil()
dependencies {
implementation(projects.anvilannotations)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.matrix.api)
api(projects.libraries.usersearch.api)
}

View file

@ -0,0 +1,36 @@
/*
* 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.changeroommemberroes.api
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
interface ChangeRoomMemberRolesEntryPoint : FeatureEntryPoint {
fun builder(parentNode: Node, buildContext: BuildContext): Builder
interface Builder {
fun room(room: JoinedRoom): Builder
fun listType(changeRoomMemberRolesListType: ChangeRoomMemberRolesListType): Builder
fun build(): Node
}
interface NodeProxy {
val roomId: RoomId
suspend fun waitForRoleChanged()
}
}
enum class ChangeRoomMemberRolesListType : NodeInputs {
SelectNewOwnersWhenLeaving,
Admins,
Moderators
}

View file

@ -0,0 +1,51 @@
import extension.setupAnvil
/*
* 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.
*/
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.changeroommemberroles.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
setupAnvil()
dependencies {
api(projects.features.changeroommemberroles.api)
implementation(projects.appnav)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.api)
// For test fixtures used in previews
implementation(projects.libraries.previewutils)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings)
implementation(projects.services.analytics.api)
testImplementation(projects.services.analytics.test)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
}

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.roomdetails.impl.rolesandpermissions.changeroles package io.element.android.features.changeroommemberroles.impl
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser

View file

@ -1,14 +1,15 @@
/* /*
* Copyright 2024 New Vector Ltd. * Copyright 2025 New Vector Ltd.
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* 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.roomdetails.impl.rolesandpermissions.changeroles package io.element.android.features.changeroommemberroles.impl
import android.os.Parcelable
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
@ -16,11 +17,13 @@ import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesListType
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.appyx.launchMolecule
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.parcelize.Parcelize import kotlinx.coroutines.flow.first
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
class ChangeRolesNode @AssistedInject constructor( class ChangeRolesNode @AssistedInject constructor(
@ -28,31 +31,30 @@ class ChangeRolesNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
presenterFactory: ChangeRolesPresenter.Factory, presenterFactory: ChangeRolesPresenter.Factory,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
sealed interface ListType : Parcelable {
@Parcelize
data object Admins : ListType
@Parcelize
data object Moderators : ListType
}
@Parcelize
data class Inputs( data class Inputs(
val listType: ListType, val listType: ChangeRoomMemberRolesListType,
) : NodeInputs, Parcelable ) : NodeInputs
private val inputs: Inputs = inputs() private val inputs: Inputs = inputs()
private val presenter = presenterFactory.run { private val presenter = presenterFactory.run {
val role = when (inputs.listType) { val role = when (inputs.listType) {
is ListType.Admins -> RoomMember.Role.Admin ChangeRoomMemberRolesListType.Admins -> RoomMember.Role.Admin
is ListType.Moderators -> RoomMember.Role.Moderator ChangeRoomMemberRolesListType.Moderators -> RoomMember.Role.Moderator
ChangeRoomMemberRolesListType.SelectNewOwnersWhenLeaving -> RoomMember.Role.Owner(isCreator = false)
} }
create(role) create(role)
} }
private val stateFlow = launchMolecule { presenter.present() }
suspend fun waitForRoleChanged() {
stateFlow.first { it.savingState.isSuccess() }
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state by stateFlow.collectAsState()
ChangeRolesView( ChangeRolesView(
modifier = modifier, modifier = modifier,
state = state, state = state,

View file

@ -1,11 +1,11 @@
/* /*
* Copyright 2024 New Vector Ltd. * Copyright 2025 New Vector Ltd.
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* 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.roomdetails.impl.rolesandpermissions.changeroles package io.element.android.features.changeroommemberroles.impl
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -22,9 +22,6 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.RoomModeration import im.vector.app.features.analytics.plan.RoomModeration
import io.element.android.features.roomdetails.impl.analytics.toAnalyticsMemberRole
import io.element.android.features.roomdetails.impl.members.PowerLevelRoomMemberComparator
import io.element.android.features.roomdetails.impl.members.RoomMemberListDataSource
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@ -37,6 +34,7 @@ import io.element.android.libraries.matrix.api.room.powerlevels.usersWithRole
import io.element.android.libraries.matrix.api.room.toMatrixUser import io.element.android.libraries.matrix.api.room.toMatrixUser
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.roleOf import io.element.android.libraries.matrix.ui.model.roleOf
import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.PersistentList
@ -136,8 +134,9 @@ class ChangeRolesPresenter @AssistedInject constructor(
val isModifyingAdmins = role == RoomMember.Role.Admin val isModifyingAdmins = role == RoomMember.Role.Admin
val hasChanges = selectedUsers != usersWithRole val hasChanges = selectedUsers != usersWithRole
val isConfirming = saveState.value.isConfirming() val isConfirming = saveState.value.isConfirming()
val modifyingOwners = role is RoomMember.Role.Owner
val needsConfirmation = currentUserIsAdmin && isModifyingAdmins && hasChanges && !isConfirming val needsConfirmation = (modifyingOwners || currentUserIsAdmin && isModifyingAdmins) && hasChanges && !isConfirming
when { when {
needsConfirmation -> { needsConfirmation -> {
@ -229,3 +228,10 @@ class ChangeRolesPresenter @AssistedInject constructor(
} }
} }
} }
internal fun RoomMember.Role.toAnalyticsMemberRole(): RoomModeration.Role = when (this) {
is RoomMember.Role.Owner -> RoomModeration.Role.Administrator // TODO - distinguish creator from admin
RoomMember.Role.Admin -> RoomModeration.Role.Administrator
RoomMember.Role.Moderator -> RoomModeration.Role.Moderator
RoomMember.Role.User -> RoomModeration.Role.User
}

View file

@ -5,14 +5,14 @@
* 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.roomdetails.impl.rolesandpermissions.changeroles package io.element.android.features.changeroommemberroles.impl
import io.element.android.features.roomdetails.impl.members.PowerLevelRoomMemberComparator
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList

View file

@ -5,11 +5,9 @@
* 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.roomdetails.impl.rolesandpermissions.changeroles package io.element.android.features.changeroommemberroles.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.aRoomMemberList
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@ -18,6 +16,8 @@ import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.previewutils.room.aRoomMember
import io.element.android.libraries.previewutils.room.aRoomMemberList
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -44,6 +44,7 @@ class ChangeRolesStateProvider : PreviewParameterProvider<ChangeRolesState> {
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Success(Unit)), aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Success(Unit)),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Failure(Exception("boom"))), aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Failure(Exception("boom"))),
aChangeRolesStateWithOwners(), aChangeRolesStateWithOwners(),
aChangeRolesStateWithOwners().copy(role = RoomMember.Role.Owner(isCreator = false)),
) )
} }

View file

@ -1,11 +1,11 @@
/* /*
* Copyright 2024 New Vector Ltd. * Copyright 2025 New Vector Ltd.
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* 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.roomdetails.impl.rolesandpermissions.changeroles package io.element.android.features.changeroommemberroles.impl
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
@ -40,7 +40,6 @@ 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.features.roomdetails.impl.R
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.async.AsyncActionView
@ -87,7 +86,6 @@ fun ChangeRolesView(
BackHandler(enabled = !state.isSearchActive) { BackHandler(enabled = !state.isSearchActive) {
state.eventSink(ChangeRolesEvent.Exit) state.eventSink(ChangeRolesEvent.Exit)
} }
Box(modifier = modifier) { Box(modifier = modifier) {
Scaffold( Scaffold(
modifier = Modifier modifier = Modifier
@ -97,9 +95,10 @@ fun ChangeRolesView(
AnimatedVisibility(visible = !state.isSearchActive) { AnimatedVisibility(visible = !state.isSearchActive) {
TopAppBar( TopAppBar(
titleStr = when (state.role) { titleStr = when (state.role) {
is RoomMember.Role.Owner -> stringResource(R.string.screen_room_change_role_owners_title)
RoomMember.Role.Admin -> stringResource(R.string.screen_room_change_role_administrators_title) RoomMember.Role.Admin -> stringResource(R.string.screen_room_change_role_administrators_title)
RoomMember.Role.Moderator -> stringResource(R.string.screen_room_change_role_moderators_title) RoomMember.Role.Moderator -> stringResource(R.string.screen_room_change_role_moderators_title)
is RoomMember.Role.Owner, RoomMember.Role.User -> error("This should never be reached") RoomMember.Role.User -> error("This should never be reached")
}, },
navigationIcon = { navigationIcon = {
BackButton(onClick = { state.eventSink(ChangeRolesEvent.Exit) }) BackButton(onClick = { state.eventSink(ChangeRolesEvent.Exit) })
@ -188,14 +187,26 @@ fun ChangeRolesView(
when (state.savingState) { when (state.savingState) {
is AsyncAction.Confirming -> { is AsyncAction.Confirming -> {
if (state.role == RoomMember.Role.Admin) { when (state.role) {
// Confirm adding new admins dialogs is RoomMember.Role.Owner -> {
ConfirmationDialog( ConfirmationDialog(
title = stringResource(R.string.screen_room_change_role_confirm_add_admin_title), title = stringResource(R.string.screen_room_change_role_confirm_change_owners_title),
content = stringResource(R.string.screen_room_change_role_confirm_add_admin_description), content = stringResource(R.string.screen_room_change_role_confirm_change_owners_description),
onSubmitClick = { state.eventSink(ChangeRolesEvent.Save) }, submitText = stringResource(CommonStrings.action_continue),
onDismiss = { state.eventSink(ChangeRolesEvent.ClearError) } onSubmitClick = { state.eventSink(ChangeRolesEvent.Save) },
) onDismiss = { state.eventSink(ChangeRolesEvent.ClearError) },
destructiveSubmit = true,
)
}
is RoomMember.Role.Admin -> {
ConfirmationDialog(
title = stringResource(R.string.screen_room_change_role_confirm_add_admin_title),
content = stringResource(R.string.screen_room_change_role_confirm_add_admin_description),
onSubmitClick = { state.eventSink(ChangeRolesEvent.Save) },
onDismiss = { state.eventSink(ChangeRolesEvent.ClearError) }
)
}
else -> Unit // No confirmation needed for Moderator or User roles
} }
} }
is AsyncAction.Loading -> { is AsyncAction.Loading -> {

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.
*/
package io.element.android.features.changeroommemberroles.impl
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.ParentNode
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.appnav.di.RoomComponentFactory
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesListType
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.DaggerComponentOwner
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
class ChangeRoomMemberRolesRootNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
roomComponentFactory: RoomComponentFactory,
) : ParentNode<ChangeRoomMemberRolesRootNode.NavTarget>(
navModel = PermanentNavModel(
navTargets = setOf(NavTarget.Root),
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
), DaggerComponentOwner, ChangeRoomMemberRolesEntryPoint.NodeProxy {
sealed interface NavTarget : Parcelable {
@Parcelize
object Root : NavTarget
}
data class Inputs(
val joinedRoom: JoinedRoom,
val listType: ChangeRoomMemberRolesListType,
) : NodeInputs
private val inputs = inputs<Inputs>()
override val daggerComponent = roomComponentFactory.create(inputs.joinedRoom)
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Root -> {
createNode<ChangeRolesNode>(
buildContext = buildContext,
plugins = listOf(ChangeRolesNode.Inputs(listType = inputs.listType)),
)
}
}
}
@Composable
override fun View(modifier: Modifier) {
Children(modifier = modifier, navModel = navModel)
}
override val roomId: RoomId = inputs.joinedRoom.roomId
override suspend fun waitForRoleChanged() {
waitForChildAttached<ChangeRolesNode>().waitForRoleChanged()
}
}

View file

@ -0,0 +1,47 @@
/*
* 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.changeroommemberroles.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesListType
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.room.JoinedRoom
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultChangeRoomMemberRolesEntyPoint @Inject constructor() : ChangeRoomMemberRolesEntryPoint {
override fun builder(parentNode: Node, buildContext: BuildContext): ChangeRoomMemberRolesEntryPoint.Builder {
return object : ChangeRoomMemberRolesEntryPoint.Builder {
private lateinit var changeRoomMemberRolesListType: ChangeRoomMemberRolesListType
private lateinit var room: JoinedRoom
override fun room(room: JoinedRoom): ChangeRoomMemberRolesEntryPoint.Builder {
this.room = room
return this
}
override fun listType(changeRoomMemberRolesListType: ChangeRoomMemberRolesListType): ChangeRoomMemberRolesEntryPoint.Builder {
this.changeRoomMemberRolesListType = changeRoomMemberRolesListType
return this
}
override fun build(): Node {
return parentNode.createNode<ChangeRoomMemberRolesRootNode>(
buildContext = buildContext,
plugins = listOf(
ChangeRoomMemberRolesRootNode.Inputs(joinedRoom = room, listType = changeRoomMemberRolesListType),
)
)
}
}
}
}

View file

@ -0,0 +1,37 @@
/*
* 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.changeroommemberroles.impl
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.roomMembers
import kotlinx.coroutines.withContext
import javax.inject.Inject
class RoomMemberListDataSource @Inject constructor(
private val room: BaseRoom,
private val coroutineDispatchers: CoroutineDispatchers,
) {
suspend fun search(query: String): List<RoomMember> = withContext(coroutineDispatchers.io) {
val roomMembersState = room.membersStateFlow.value
val activeRoomMembers = roomMembersState.roomMembers()
?.filter { it.membership.isActive() }
.orEmpty()
val filteredMembers = if (query.isBlank()) {
activeRoomMembers
} else {
activeRoomMembers.filter { member ->
member.userId.value.contains(query, ignoreCase = true) ||
member.displayName?.contains(query, ignoreCase = true).orFalse()
}
}
filteredMembers
}
}

View file

@ -0,0 +1,70 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_change_permissions_administrators">"Admins only"</string>
<string name="screen_room_change_permissions_ban_people">"Ban people"</string>
<string name="screen_room_change_permissions_delete_messages">"Remove messages"</string>
<string name="screen_room_change_permissions_everyone">"Everyone"</string>
<string name="screen_room_change_permissions_invite_people">"Invite people and accept requests to join"</string>
<string name="screen_room_change_permissions_member_moderation">"Member moderation"</string>
<string name="screen_room_change_permissions_messages_and_content">"Messages and content"</string>
<string name="screen_room_change_permissions_moderators">"Admins and moderators"</string>
<string name="screen_room_change_permissions_remove_people">"Remove people and decline requests to join"</string>
<string name="screen_room_change_permissions_room_avatar">"Change room avatar"</string>
<string name="screen_room_change_permissions_room_details">"Room details"</string>
<string name="screen_room_change_permissions_room_name">"Change room name"</string>
<string name="screen_room_change_permissions_room_topic">"Change room topic"</string>
<string name="screen_room_change_permissions_send_messages">"Send messages"</string>
<string name="screen_room_change_role_administrators_title">"Edit Admins"</string>
<string name="screen_room_change_role_confirm_add_admin_description">"You will not be able to undo this action. You are promoting the user to have the same power level as you."</string>
<string name="screen_room_change_role_confirm_add_admin_title">"Add Admin?"</string>
<string name="screen_room_change_role_confirm_change_owners_description">"You will not be able to undo this action. You are transferring the ownership to the selected users. Once you leave this will be permanent."</string>
<string name="screen_room_change_role_confirm_change_owners_title">"Transfer ownership?"</string>
<string name="screen_room_change_role_confirm_demote_self_action">"Demote"</string>
<string name="screen_room_change_role_confirm_demote_self_description">"You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges."</string>
<string name="screen_room_change_role_confirm_demote_self_title">"Demote yourself?"</string>
<string name="screen_room_change_role_invited_member_name">"%1$s (Pending)"</string>
<string name="screen_room_change_role_invited_member_name_android">"(Pending)"</string>
<string name="screen_room_change_role_moderators_admin_section_footer">"Admins automatically have moderator privileges"</string>
<string name="screen_room_change_role_moderators_owner_section_footer">"Owners automatically have admin privileges."</string>
<string name="screen_room_change_role_moderators_title">"Edit Moderators"</string>
<string name="screen_room_change_role_owners_title">"Choose Owners"</string>
<string name="screen_room_change_role_section_administrators">"Admins"</string>
<string name="screen_room_change_role_section_moderators">"Moderators"</string>
<string name="screen_room_change_role_section_users">"Members"</string>
<string name="screen_room_change_role_unsaved_changes_description">"You have unsaved changes."</string>
<string name="screen_room_change_role_unsaved_changes_title">"Save changes?"</string>
<string name="screen_room_member_list_banned_empty">"There are no banned users in this room."</string>
<plurals name="screen_room_member_list_header_title">
<item quantity="one">"%1$d person"</item>
<item quantity="other">"%1$d people"</item>
</plurals>
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Ban from room"</string>
<string name="screen_room_member_list_manage_member_remove_confirmation_kick">"Only remove member"</string>
<string name="screen_room_member_list_manage_member_unban_action">"Unban"</string>
<string name="screen_room_member_list_manage_member_unban_message">"They will be able to join this room again if invited."</string>
<string name="screen_room_member_list_manage_member_unban_title">"Unban user"</string>
<string name="screen_room_member_list_mode_banned">"Banned"</string>
<string name="screen_room_member_list_mode_members">"Members"</string>
<string name="screen_room_member_list_pending_header_title">"Pending"</string>
<string name="screen_room_member_list_role_administrator">"Admin"</string>
<string name="screen_room_member_list_role_moderator">"Moderator"</string>
<string name="screen_room_member_list_role_owner">"Owner"</string>
<string name="screen_room_member_list_room_members_header_title">"Room members"</string>
<string name="screen_room_member_list_unbanning_user">"Unbanning %1$s"</string>
<string name="screen_room_roles_and_permissions_admins">"Admins"</string>
<string name="screen_room_roles_and_permissions_admins_and_owners">"Admins and owners"</string>
<string name="screen_room_roles_and_permissions_change_my_role">"Change my role"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_member">"Demote to member"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_moderator">"Demote to moderator"</string>
<string name="screen_room_roles_and_permissions_member_moderation">"Member moderation"</string>
<string name="screen_room_roles_and_permissions_messages_and_content">"Messages and content"</string>
<string name="screen_room_roles_and_permissions_moderators">"Moderators"</string>
<string name="screen_room_roles_and_permissions_owners">"Owners"</string>
<string name="screen_room_roles_and_permissions_permissions_header">"Permissions"</string>
<string name="screen_room_roles_and_permissions_reset">"Reset permissions"</string>
<string name="screen_room_roles_and_permissions_reset_confirm_description">"Once you reset permissions, you will lose the current settings."</string>
<string name="screen_room_roles_and_permissions_reset_confirm_title">"Reset permissions?"</string>
<string name="screen_room_roles_and_permissions_roles_header">"Roles"</string>
<string name="screen_room_roles_and_permissions_room_details">"Room details"</string>
<string name="screen_room_roles_and_permissions_title">"Roles and permissions"</string>
</resources>

View file

@ -1,19 +1,17 @@
/* /*
* Copyright 2024 New Vector Ltd. * Copyright 2025 New Vector Ltd.
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* 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.roomdetails.impl.rolesandpermissions.changeroles package io.element.android.features.changeroommemberroles.impl
import app.cash.molecule.RecompositionMode import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.RoomModeration import im.vector.app.features.analytics.plan.RoomModeration
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.aRoomMemberList
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
@ -28,14 +26,18 @@ import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.room.FakeBaseRoom import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.defaultRoomPowerLevelValues import io.element.android.libraries.matrix.test.room.defaultRoomPowerLevelValues
import io.element.android.libraries.previewutils.room.aRoomMemberList
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
import kotlinx.collections.immutable.toPersistentMap
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import kotlin.collections.plus
class ChangeRolesPresenterTest { class ChangeRolesPresenterTest {
@Test @Test
@ -430,6 +432,44 @@ class ChangeRolesPresenterTest {
} }
} }
@Test
fun `present - Save will ask for confirmation before assigning new owners`() = runTest {
val analyticsService = FakeAnalyticsService()
val room = FakeJoinedRoom(
updateUserRoleResult = { Result.success(Unit) },
baseRoom = FakeBaseRoom(updateMembersResult = { Result.success(Unit) }),
).apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(
aRoomInfo(
roomCreators = listOf(sessionId),
roomPowerLevels = roomPowerLevelsWithRoles(
A_USER_ID to RoomMember.Role.Owner(isCreator = false),
A_USER_ID_2 to RoomMember.Role.Admin,
)
)
)
}
val presenter = createChangeRolesPresenter(
role = RoomMember.Role.Owner(isCreator = false),
room = room,
analyticsService = analyticsService
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.selectedUsers).hasSize(1)
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
awaitItem().eventSink(ChangeRolesEvent.Save)
assertThat(awaitItem().savingState.isConfirming()).isTrue()
}
}
@Test @Test
fun `present - Save will just save the changes if the current user is a room creator and the selected users are not`() = runTest { fun `present - Save will just save the changes if the current user is a room creator and the selected users are not`() = runTest {
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
@ -510,9 +550,16 @@ class ChangeRolesPresenterTest {
) )
} }
private fun roomPowerLevelsWithRoles(vararg pairs: Pair<UserId, RoomMember.Role>): RoomPowerLevels {
return RoomPowerLevels(
values = defaultRoomPowerLevelValues(),
users = pairs.associate { (userId, role) -> userId to role.powerLevel }.toPersistentMap()
)
}
private fun TestScope.createChangeRolesPresenter( private fun TestScope.createChangeRolesPresenter(
role: RoomMember.Role = RoomMember.Role.Admin, role: RoomMember.Role = RoomMember.Role.Admin,
room: FakeJoinedRoom = FakeJoinedRoom(), room: FakeJoinedRoom = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {})),
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(), dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(), analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
): ChangeRolesPresenter { ): ChangeRolesPresenter {

View file

@ -1,11 +1,11 @@
/* /*
* Copyright 2024 New Vector Ltd. * Copyright 2025 New Vector Ltd.
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* 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.roomdetails.impl.rolesandpermissions.changeroles package io.element.android.features.changeroommemberroles.impl
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.AndroidComposeTestRule
@ -54,20 +54,6 @@ class ChangeRolesViewTest {
assertThat(exception).isNotNull() assertThat(exception).isNotNull()
} }
@Test
fun `passing an 'Owner' role throws an exception`() {
val exception = runCatchingExceptions {
rule.setChangeRolesContent(
state = aChangeRolesState(
role = RoomMember.Role.Owner(isCreator = true),
eventSink = EnsureNeverCalledWithParam(),
),
)
}.exceptionOrNull()
assertThat(exception).isNotNull()
}
@Test @Test
fun `back key - with search active toggles the search`() { fun `back key - with search active toggles the search`() {
val eventsRecorder = EventsRecorder<ChangeRolesEvent>() val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
@ -192,6 +178,23 @@ class ChangeRolesViewTest {
eventsRecorder.assertSingle(ChangeRolesEvent.Save) eventsRecorder.assertSingle(ChangeRolesEvent.Save)
} }
@Test
fun `save owners confirmation dialog - continue saves the changes`() {
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
rule.setChangeRolesContent(
state = aChangeRolesState(
role = RoomMember.Role.Owner(isCreator = false),
isSearchActive = true,
savingState = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(ChangeRolesEvent.Save)
}
@Test @Test
fun `save confirmation dialog - cancel removes the dialog`() { fun `save confirmation dialog - cancel removes the dialog`() {
val eventsRecorder = EventsRecorder<ChangeRolesEvent>() val eventsRecorder = EventsRecorder<ChangeRolesEvent>()

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.roomdetails.impl.rolesandpermissions.changeroles package io.element.android.features.changeroommemberroles.impl
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember

View file

@ -54,6 +54,7 @@ dependencies {
implementation(libs.haze) implementation(libs.haze)
implementation(libs.haze.materials) implementation(libs.haze.materials)
implementation(projects.features.reportroom.api) implementation(projects.features.reportroom.api)
implementation(projects.features.changeroommemberroles.api)
api(projects.features.home.api) api(projects.features.home.api)
testImplementation(libs.androidx.compose.ui.test.junit) testImplementation(libs.androidx.compose.ui.test.junit)

View file

@ -11,7 +11,11 @@ import android.app.Activity
import android.os.Parcelable import android.os.Parcelable
import androidx.activity.compose.LocalActivity import androidx.activity.compose.LocalActivity
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 androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.lifecycle.subscribe
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
@ -19,31 +23,43 @@ 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.core.plugin.plugins import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
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 dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesListType
import io.element.android.features.home.api.HomeEntryPoint import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.features.home.impl.components.RoomListMenuAction import io.element.android.features.home.impl.components.RoomListMenuAction
import io.element.android.features.home.impl.model.RoomListRoomSummary import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.home.impl.roomlist.RoomListEvents
import io.element.android.features.invite.api.InviteData import io.element.android.features.invite.api.InviteData
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteView import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteView
import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint
import io.element.android.features.leaveroom.api.LeaveRoomRenderer
import io.element.android.features.logout.api.direct.DirectLogoutView import io.element.android.features.logout.api.direct.DirectLogoutView
import io.element.android.features.reportroom.api.ReportRoomEntryPoint import io.element.android.features.reportroom.api.ReportRoomEntryPoint
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.appyx.launchMolecule
import io.element.android.libraries.deeplink.usecase.InviteFriendsUseCase import io.element.android.libraries.deeplink.usecase.InviteFriendsUseCase
import io.element.android.libraries.di.SessionScope 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.matrix.api.core.RoomId
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
class HomeFlowNode @AssistedInject constructor( class HomeFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val matrixClient: MatrixClient,
private val presenter: HomePresenter, private val presenter: HomePresenter,
private val inviteFriendsUseCase: InviteFriendsUseCase, private val inviteFriendsUseCase: InviteFriendsUseCase,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
@ -51,6 +67,8 @@ class HomeFlowNode @AssistedInject constructor(
private val directLogoutView: DirectLogoutView, private val directLogoutView: DirectLogoutView,
private val reportRoomEntryPoint: ReportRoomEntryPoint, private val reportRoomEntryPoint: ReportRoomEntryPoint,
private val declineInviteAndBlockUserEntryPoint: DeclineInviteAndBlockEntryPoint, private val declineInviteAndBlockUserEntryPoint: DeclineInviteAndBlockEntryPoint,
private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint,
private val leaveRoomRenderer: LeaveRoomRenderer,
) : BaseFlowNode<HomeFlowNode.NavTarget>( ) : BaseFlowNode<HomeFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Root, initialElement = NavTarget.Root,
@ -59,12 +77,25 @@ class HomeFlowNode @AssistedInject constructor(
buildContext = buildContext, buildContext = buildContext,
plugins = plugins plugins = plugins
) { ) {
init { private val stateFlow = launchMolecule { presenter.present() }
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe( lifecycle.subscribe(
onResume = { onResume = {
analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.Home)) analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.Home))
} }
) )
whenChildAttached { commonLifecycle: Lifecycle,
changeRoomMemberRolesNode: ChangeRoomMemberRolesEntryPoint.NodeProxy ->
commonLifecycle.coroutineScope.launch {
changeRoomMemberRolesNode.waitForRoleChanged()
withContext(NonCancellable) {
backstack.pop()
onNewOwnersSelected(changeRoomMemberRolesNode.roomId)
}
}
}
} }
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@ -76,6 +107,9 @@ class HomeFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data class DeclineInviteAndBlockUser(val inviteData: InviteData) : NavTarget data class DeclineInviteAndBlockUser(val inviteData: InviteData) : NavTarget
@Parcelize
data class SelectNewOwnersWhenLeavingRoom(val roomId: RoomId) : NavTarget
} }
private fun onRoomClick(roomId: RoomId) { private fun onRoomClick(roomId: RoomId) {
@ -121,11 +155,18 @@ class HomeFlowNode @AssistedInject constructor(
} }
} }
private fun onSelectNewOwnersWhenLeavingRoom(roomId: RoomId) {
backstack.push(NavTarget.SelectNewOwnersWhenLeavingRoom(roomId))
}
private fun onNewOwnersSelected(roomId: RoomId) {
stateFlow.value.roomListState.eventSink(RoomListEvents.LeaveRoom(roomId, needsConfirmation = false))
}
fun rootNode(buildContext: BuildContext): Node { fun rootNode(buildContext: BuildContext): Node {
return node(buildContext) { modifier -> return node(buildContext) { modifier ->
val state = presenter.present() val state by stateFlow.collectAsState()
val activity = requireNotNull(LocalActivity.current) val activity = requireNotNull(LocalActivity.current)
HomeView( HomeView(
homeState = state, homeState = state,
onRoomClick = this::onRoomClick, onRoomClick = this::onRoomClick,
@ -138,15 +179,22 @@ class HomeFlowNode @AssistedInject constructor(
onReportRoomClick = this::onReportRoomClick, onReportRoomClick = this::onReportRoomClick,
onDeclineInviteAndBlockUser = this::onDeclineInviteAndBlockUserClick, onDeclineInviteAndBlockUser = this::onDeclineInviteAndBlockUserClick,
modifier = modifier, modifier = modifier,
) { acceptDeclineInviteView = {
acceptDeclineInviteView.Render( acceptDeclineInviteView.Render(
state = state.roomListState.acceptDeclineInviteState, state = state.roomListState.acceptDeclineInviteState,
onAcceptInviteSuccess = this::onRoomClick, onAcceptInviteSuccess = this::onRoomClick,
onDeclineInviteSuccess = { }, onDeclineInviteSuccess = { },
modifier = Modifier modifier = Modifier
) )
} },
leaveRoomView = {
leaveRoomRenderer.Render(
state = state.roomListState.leaveRoomState,
onSelectNewOwners = this::onSelectNewOwnersWhenLeavingRoom,
modifier = Modifier
)
}
)
directLogoutView.Render(state.directLogoutState) directLogoutView.Render(state.directLogoutState)
} }
} }
@ -160,6 +208,13 @@ class HomeFlowNode @AssistedInject constructor(
return when (navTarget) { return when (navTarget) {
is NavTarget.ReportRoom -> reportRoomEntryPoint.createNode(this, buildContext, navTarget.roomId) is NavTarget.ReportRoom -> reportRoomEntryPoint.createNode(this, buildContext, navTarget.roomId)
is NavTarget.DeclineInviteAndBlockUser -> declineInviteAndBlockUserEntryPoint.createNode(this, buildContext, navTarget.inviteData) is NavTarget.DeclineInviteAndBlockUser -> declineInviteAndBlockUserEntryPoint.createNode(this, buildContext, navTarget.inviteData)
is NavTarget.SelectNewOwnersWhenLeavingRoom -> {
val room = runBlocking { matrixClient.getJoinedRoom(navTarget.roomId) } ?: error("Room ${navTarget.roomId} not found")
changeRoomMemberRolesEntryPoint.builder(this, buildContext)
.room(room)
.listType(ChangeRoomMemberRolesListType.SelectNewOwnersWhenLeaving)
.build()
}
NavTarget.Root -> rootNode(buildContext) NavTarget.Root -> rootNode(buildContext)
} }
} }

View file

@ -49,7 +49,6 @@ import io.element.android.features.home.impl.roomlist.RoomListDeclineInviteMenu
import io.element.android.features.home.impl.roomlist.RoomListEvents import io.element.android.features.home.impl.roomlist.RoomListEvents
import io.element.android.features.home.impl.roomlist.RoomListState import io.element.android.features.home.impl.roomlist.RoomListState
import io.element.android.features.home.impl.search.RoomListSearchView import io.element.android.features.home.impl.search.RoomListSearchView
import io.element.android.features.leaveroom.api.LeaveRoomView
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer
import io.element.android.libraries.androidutils.throttler.FirstThrottler import io.element.android.libraries.androidutils.throttler.FirstThrottler
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@ -78,8 +77,9 @@ fun HomeView(
onMenuActionClick: (RoomListMenuAction) -> Unit, onMenuActionClick: (RoomListMenuAction) -> Unit,
onReportRoomClick: (roomId: RoomId) -> Unit, onReportRoomClick: (roomId: RoomId) -> Unit,
onDeclineInviteAndBlockUser: (roomSummary: RoomListRoomSummary) -> Unit, onDeclineInviteAndBlockUser: (roomSummary: RoomListRoomSummary) -> Unit,
modifier: Modifier = Modifier,
acceptDeclineInviteView: @Composable () -> Unit, acceptDeclineInviteView: @Composable () -> Unit,
modifier: Modifier = Modifier,
leaveRoomView: @Composable () -> Unit,
) { ) {
val state: RoomListState = homeState.roomListState val state: RoomListState = homeState.roomListState
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@ -108,7 +108,7 @@ fun HomeView(
) )
} }
LeaveRoomView(state = state.leaveRoomState) leaveRoomView()
HomeScaffold( HomeScaffold(
state = homeState, state = homeState,
@ -304,5 +304,6 @@ internal fun HomeViewPreview(@PreviewParameter(HomeStateProvider::class) state:
onMenuActionClick = {}, onMenuActionClick = {},
onDeclineInviteAndBlockUser = {}, onDeclineInviteAndBlockUser = {},
acceptDeclineInviteView = {}, acceptDeclineInviteView = {},
leaveRoomView = {}
) )
} }

View file

@ -60,7 +60,7 @@ fun RoomListContextMenu(
}, },
onLeaveRoomClick = { onLeaveRoomClick = {
eventSink(RoomListEvents.HideContextMenu) eventSink(RoomListEvents.HideContextMenu)
eventSink(RoomListEvents.LeaveRoom(contextMenu.roomId)) eventSink(RoomListEvents.LeaveRoom(contextMenu.roomId, needsConfirmation = true))
}, },
onFavoriteChange = { isFavorite -> onFavoriteChange = { isFavorite ->
eventSink(RoomListEvents.SetRoomIsFavorite(contextMenu.roomId, isFavorite)) eventSink(RoomListEvents.SetRoomIsFavorite(contextMenu.roomId, isFavorite))

View file

@ -24,7 +24,7 @@ sealed interface RoomListEvents {
sealed interface ContextMenuEvents : RoomListEvents sealed interface ContextMenuEvents : RoomListEvents
data object HideContextMenu : ContextMenuEvents data object HideContextMenu : ContextMenuEvents
data class LeaveRoom(val roomId: RoomId) : ContextMenuEvents data class LeaveRoom(val roomId: RoomId, val needsConfirmation: Boolean) : ContextMenuEvents
data class MarkAsRead(val roomId: RoomId) : ContextMenuEvents data class MarkAsRead(val roomId: RoomId) : ContextMenuEvents
data class MarkAsUnread(val roomId: RoomId) : ContextMenuEvents data class MarkAsUnread(val roomId: RoomId) : ContextMenuEvents
data class SetRoomIsFavorite(val roomId: RoomId, val isFavorite: Boolean) : ContextMenuEvents data class SetRoomIsFavorite(val roomId: RoomId, val isFavorite: Boolean) : ContextMenuEvents

View file

@ -31,7 +31,7 @@ import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents.AcceptInvite import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents.AcceptInvite
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents.DeclineInvite import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents.DeclineInvite
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.leaveroom.api.LeaveRoomEvent.ShowConfirmation import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@ -127,7 +127,9 @@ class RoomListPresenter @Inject constructor(
is RoomListEvents.HideContextMenu -> { is RoomListEvents.HideContextMenu -> {
contextMenu.value = RoomListState.ContextMenu.Hidden contextMenu.value = RoomListState.ContextMenu.Hidden
} }
is RoomListEvents.LeaveRoom -> leaveRoomState.eventSink(ShowConfirmation(event.roomId)) is RoomListEvents.LeaveRoom -> {
leaveRoomState.eventSink(LeaveRoomEvent.LeaveRoom(event.roomId, needsConfirmation = event.needsConfirmation))
}
is RoomListEvents.SetRoomIsFavorite -> coroutineScope.setRoomIsFavorite(event.roomId, event.isFavorite) is RoomListEvents.SetRoomIsFavorite -> coroutineScope.setRoomIsFavorite(event.roomId, event.isFavorite)
is RoomListEvents.MarkAsRead -> coroutineScope.markAsRead(event.roomId) is RoomListEvents.MarkAsRead -> coroutineScope.markAsRead(event.roomId)
is RoomListEvents.MarkAsUnread -> coroutineScope.markAsUnread(event.roomId) is RoomListEvents.MarkAsUnread -> coroutineScope.markAsUnread(event.roomId)

View file

@ -18,8 +18,8 @@ import io.element.android.features.home.impl.search.RoomListSearchState
import io.element.android.features.home.impl.search.aRoomListSearchState import io.element.android.features.home.impl.search.aRoomListSearchState
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.aLeaveRoomState
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
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
@ -70,6 +70,12 @@ internal fun aRoomListState(
eventSink = eventSink, eventSink = eventSink,
) )
internal fun aLeaveRoomState(
eventSink: (LeaveRoomEvent) -> Unit = {}
) = object : LeaveRoomState {
override val eventSink: (LeaveRoomEvent) -> Unit = eventSink
}
internal fun anAcceptDeclineInviteState( internal fun anAcceptDeclineInviteState(
acceptAction: AsyncAction<RoomId> = AsyncAction.Uninitialized, acceptAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
declineAction: AsyncAction<RoomId> = AsyncAction.Uninitialized, declineAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,

View file

@ -73,7 +73,7 @@ class RoomListContextMenuTest {
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
RoomListEvents.HideContextMenu, RoomListEvents.HideContextMenu,
RoomListEvents.LeaveRoom(contextMenu.roomId), RoomListEvents.LeaveRoom(contextMenu.roomId, needsConfirmation = true),
) )
) )
} }

View file

@ -27,7 +27,6 @@ import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteS
import io.element.android.features.invite.test.InMemorySeenInvitesStore import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.aLeaveRoomState
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.dateformatter.test.FakeDateFormatter
@ -319,8 +318,8 @@ class RoomListPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(RoomListEvents.LeaveRoom(A_ROOM_ID)) initialState.eventSink(RoomListEvents.LeaveRoom(A_ROOM_ID, needsConfirmation = true))
leaveRoomEventsRecorder.assertSingle(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID)) leaveRoomEventsRecorder.assertSingle(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true))
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }

View file

@ -289,7 +289,8 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomL
onMenuActionClick = onMenuActionClick, onMenuActionClick = onMenuActionClick,
onDeclineInviteAndBlockUser = onDeclineInviteAndBlockUser, onDeclineInviteAndBlockUser = onDeclineInviteAndBlockUser,
onReportRoomClick = onReportRoomClick, onReportRoomClick = onReportRoomClick,
acceptDeclineInviteView = { }, acceptDeclineInviteView = {},
leaveRoomView = {},
) )
} }
} }

View file

@ -56,15 +56,11 @@ class AcceptDeclineInvitePresenter @Inject constructor(
) )
} }
} }
is InternalAcceptDeclineInviteEvents.CancelDeclineInvite -> { is InternalAcceptDeclineInviteEvents.ClearAcceptActionState -> {
declinedAction.value = AsyncAction.Uninitialized
}
is InternalAcceptDeclineInviteEvents.DismissAcceptError -> {
acceptedAction.value = AsyncAction.Uninitialized acceptedAction.value = AsyncAction.Uninitialized
} }
is InternalAcceptDeclineInviteEvents.DismissDeclineError -> { is InternalAcceptDeclineInviteEvents.ClearDeclineActionState -> {
declinedAction.value = AsyncAction.Uninitialized declinedAction.value = AsyncAction.Uninitialized
} }
} }

View file

@ -35,9 +35,12 @@ fun AcceptDeclineInviteView(
Box(modifier = modifier) { Box(modifier = modifier) {
AsyncActionView( AsyncActionView(
async = state.acceptAction, async = state.acceptAction,
onSuccess = onAcceptInviteSuccess, onSuccess = { roomId ->
state.eventSink(InternalAcceptDeclineInviteEvents.ClearAcceptActionState)
onAcceptInviteSuccess(roomId)
},
onErrorDismiss = { onErrorDismiss = {
state.eventSink(InternalAcceptDeclineInviteEvents.DismissAcceptError) state.eventSink(InternalAcceptDeclineInviteEvents.ClearAcceptActionState)
}, },
errorTitle = { errorTitle = {
stringResource(CommonStrings.common_something_went_wrong) stringResource(CommonStrings.common_something_went_wrong)
@ -52,9 +55,12 @@ fun AcceptDeclineInviteView(
) )
AsyncActionView( AsyncActionView(
async = state.declineAction, async = state.declineAction,
onSuccess = onDeclineInviteSuccess, onSuccess = { roomId ->
state.eventSink(InternalAcceptDeclineInviteEvents.ClearDeclineActionState)
onDeclineInviteSuccess(roomId)
},
onErrorDismiss = { onErrorDismiss = {
state.eventSink(InternalAcceptDeclineInviteEvents.DismissDeclineError) state.eventSink(InternalAcceptDeclineInviteEvents.ClearDeclineActionState)
}, },
errorTitle = { errorTitle = {
stringResource(CommonStrings.common_something_went_wrong) stringResource(CommonStrings.common_something_went_wrong)
@ -78,7 +84,7 @@ fun AcceptDeclineInviteView(
) )
}, },
onDismissClick = { onDismissClick = {
state.eventSink(InternalAcceptDeclineInviteEvents.CancelDeclineInvite) state.eventSink(InternalAcceptDeclineInviteEvents.ClearDeclineActionState)
} }
) )
} }

View file

@ -10,7 +10,6 @@ package io.element.android.features.invite.impl.acceptdecline
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents
sealed interface InternalAcceptDeclineInviteEvents : AcceptDeclineInviteEvents { sealed interface InternalAcceptDeclineInviteEvents : AcceptDeclineInviteEvents {
data object CancelDeclineInvite : InternalAcceptDeclineInviteEvents data object ClearAcceptActionState : InternalAcceptDeclineInviteEvents
data object DismissAcceptError : InternalAcceptDeclineInviteEvents data object ClearDeclineActionState : InternalAcceptDeclineInviteEvents
data object DismissDeclineError : InternalAcceptDeclineInviteEvents
} }

View file

@ -56,7 +56,7 @@ class AcceptDeclineInvitePresenterTest {
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData, false)) assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData, false))
state.eventSink( state.eventSink(
InternalAcceptDeclineInviteEvents.CancelDeclineInvite InternalAcceptDeclineInviteEvents.ClearDeclineActionState
) )
} }
awaitItem().also { state -> awaitItem().also { state ->
@ -90,7 +90,7 @@ class AcceptDeclineInvitePresenterTest {
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.declineAction).isInstanceOf(AsyncAction.Failure::class.java) assertThat(state.declineAction).isInstanceOf(AsyncAction.Failure::class.java)
state.eventSink( state.eventSink(
InternalAcceptDeclineInviteEvents.DismissDeclineError InternalAcceptDeclineInviteEvents.ClearDeclineActionState
) )
} }
awaitItem().also { state -> awaitItem().also { state ->
@ -154,7 +154,7 @@ class AcceptDeclineInvitePresenterTest {
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.acceptAction).isInstanceOf(AsyncAction.Failure::class.java) assertThat(state.acceptAction).isInstanceOf(AsyncAction.Failure::class.java)
state.eventSink( state.eventSink(
InternalAcceptDeclineInviteEvents.DismissAcceptError InternalAcceptDeclineInviteEvents.ClearAcceptActionState
) )
} }
awaitItem().also { state -> awaitItem().also { state ->

View file

@ -14,7 +14,5 @@ android {
dependencies { dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
} }

View file

@ -9,9 +9,6 @@ package io.element.android.features.leaveroom.api
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
sealed interface LeaveRoomEvent { interface LeaveRoomEvent {
data class ShowConfirmation(val roomId: RoomId) : LeaveRoomEvent data class LeaveRoom(val roomId: RoomId, val needsConfirmation: Boolean) : LeaveRoomEvent
data object HideConfirmation : LeaveRoomEvent
data class LeaveRoom(val roomId: RoomId) : LeaveRoomEvent
data object HideError : LeaveRoomEvent
} }

View file

@ -0,0 +1,21 @@
/*
* 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.leaveroom.api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.libraries.matrix.api.core.RoomId
interface LeaveRoomRenderer {
@Composable
fun Render(
state: LeaveRoomState,
onSelectNewOwners: (RoomId) -> Unit,
modifier: Modifier,
)
}

View file

@ -7,29 +7,6 @@
package io.element.android.features.leaveroom.api package io.element.android.features.leaveroom.api
import io.element.android.libraries.matrix.api.core.RoomId interface LeaveRoomState {
val eventSink: (LeaveRoomEvent) -> Unit
data class LeaveRoomState(
val confirmation: Confirmation,
val progress: Progress,
val error: Error,
val eventSink: (LeaveRoomEvent) -> Unit,
) {
sealed interface Confirmation {
data object Hidden : Confirmation
data class Dm(val roomId: RoomId) : Confirmation
data class Generic(val roomId: RoomId) : Confirmation
data class PrivateRoom(val roomId: RoomId) : Confirmation
data class LastUserInRoom(val roomId: RoomId) : Confirmation
}
sealed interface Progress {
data object Hidden : Progress
data object Shown : Progress
}
sealed interface Error {
data object Hidden : Error
data object Shown : Error
}
} }

View file

@ -1,66 +0,0 @@
/*
* Copyright 2023, 2024 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.leaveroom.api
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.RoomId
class LeaveRoomStateProvider : PreviewParameterProvider<LeaveRoomState> {
override val values: Sequence<LeaveRoomState>
get() = sequenceOf(
aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.Hidden,
progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Hidden,
),
aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.Generic(roomId = A_ROOM_ID),
progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Hidden,
),
aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.PrivateRoom(roomId = A_ROOM_ID),
progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Hidden,
),
aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.LastUserInRoom(roomId = A_ROOM_ID),
progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Hidden,
),
aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.Hidden,
progress = LeaveRoomState.Progress.Shown,
error = LeaveRoomState.Error.Hidden,
),
aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.Hidden,
progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Shown,
),
aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.Dm(roomId = A_ROOM_ID),
progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Hidden,
),
)
}
private val A_ROOM_ID = RoomId("!aRoomId:aDomain")
fun aLeaveRoomState(
confirmation: LeaveRoomState.Confirmation = LeaveRoomState.Confirmation.Hidden,
progress: LeaveRoomState.Progress = LeaveRoomState.Progress.Hidden,
error: LeaveRoomState.Error = LeaveRoomState.Error.Hidden,
eventSink: (LeaveRoomEvent) -> Unit = {},
) = LeaveRoomState(
confirmation = confirmation,
progress = progress,
error = error,
eventSink = eventSink,
)

View file

@ -1,124 +0,0 @@
/*
* Copyright 2023, 2024 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.leaveroom.api
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun LeaveRoomView(
state: LeaveRoomState
) {
LeaveRoomConfirmationDialog(state)
LeaveRoomProgressDialog(state)
LeaveRoomErrorDialog(state)
}
@Composable
private fun LeaveRoomConfirmationDialog(
state: LeaveRoomState,
) {
when (state.confirmation) {
is LeaveRoomState.Confirmation.Hidden -> {}
is LeaveRoomState.Confirmation.Dm -> LeaveRoomConfirmationDialog(
text = R.string.leave_room_alert_private_subtitle,
roomId = state.confirmation.roomId,
isDm = false,
eventSink = state.eventSink,
)
is LeaveRoomState.Confirmation.PrivateRoom -> LeaveRoomConfirmationDialog(
text = R.string.leave_room_alert_private_subtitle,
roomId = state.confirmation.roomId,
isDm = false,
eventSink = state.eventSink,
)
is LeaveRoomState.Confirmation.LastUserInRoom -> LeaveRoomConfirmationDialog(
text = R.string.leave_room_alert_empty_subtitle,
roomId = state.confirmation.roomId,
isDm = false,
eventSink = state.eventSink,
)
is LeaveRoomState.Confirmation.Generic -> LeaveRoomConfirmationDialog(
text = R.string.leave_room_alert_subtitle,
roomId = state.confirmation.roomId,
isDm = false,
eventSink = state.eventSink,
)
}
}
@Composable
private fun LeaveRoomConfirmationDialog(
@StringRes text: Int,
roomId: RoomId,
isDm: Boolean,
eventSink: (LeaveRoomEvent) -> Unit,
) {
ConfirmationDialog(
title = stringResource(if (isDm) CommonStrings.action_leave_conversation else CommonStrings.action_leave_room),
content = stringResource(text),
submitText = stringResource(CommonStrings.action_leave),
onSubmitClick = { eventSink(LeaveRoomEvent.LeaveRoom(roomId)) },
onDismiss = { eventSink(LeaveRoomEvent.HideConfirmation) },
)
}
@Composable
private fun LeaveRoomProgressDialog(
state: LeaveRoomState,
) {
when (state.progress) {
is LeaveRoomState.Progress.Hidden -> {}
is LeaveRoomState.Progress.Shown -> ProgressDialog(
text = stringResource(CommonStrings.common_leaving_room),
)
}
}
@Composable
private fun LeaveRoomErrorDialog(
state: LeaveRoomState,
) {
when (state.error) {
is LeaveRoomState.Error.Hidden -> {}
is LeaveRoomState.Error.Shown -> ErrorDialog(
content = stringResource(CommonStrings.error_unknown),
onSubmit = { state.eventSink(LeaveRoomEvent.HideError) }
)
}
}
@PreviewsDayNight
@Composable
internal fun LeaveRoomViewPreview(
@PreviewParameter(LeaveRoomStateProvider::class) state: LeaveRoomState
) = ElementPreview {
Box(
modifier = Modifier.size(300.dp, 300.dp),
propagateMinConstraints = true,
) {
LeaveRoomView(state = state)
}
}

View file

@ -18,10 +18,12 @@ android {
setupAnvil() setupAnvil()
dependencies { dependencies {
api(projects.features.leaveroom.api)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
api(projects.features.leaveroom.api) implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)

View file

@ -0,0 +1,14 @@
/*
* Copyright 2023, 2024 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.leaveroom.impl
import io.element.android.features.leaveroom.api.LeaveRoomEvent
sealed interface InternalLeaveRoomEvent : LeaveRoomEvent {
data object ResetState : InternalLeaveRoomEvent
}

View file

@ -0,0 +1,29 @@
/*
* 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.leaveroom.impl
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.leaveroom.api.LeaveRoomRenderer
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class InternalLeaveRoomRenderer @Inject constructor() : LeaveRoomRenderer {
@Composable
override fun Render(state: LeaveRoomState, onSelectNewOwners: (RoomId) -> Unit, modifier: Modifier) {
if (state is InternalLeaveRoomState) {
LeaveRoomView(state, onSelectNewOwners)
} else {
error("Unsupported state type ${state.javaClass}")
}
}
}

View file

@ -0,0 +1,28 @@
/*
* 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.leaveroom.impl
import androidx.compose.runtime.Immutable
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
data class InternalLeaveRoomState(
val leaveAction: AsyncAction<Unit>,
override val eventSink: (LeaveRoomEvent) -> Unit
) : LeaveRoomState
@Immutable
sealed interface Confirmation : AsyncAction.Confirming {
data class Dm(val roomId: RoomId) : Confirmation
data class Generic(val roomId: RoomId) : Confirmation
data class PrivateRoom(val roomId: RoomId) : Confirmation
data class LastUserInRoom(val roomId: RoomId) : Confirmation
data class LastOwnerInRoom(val roomId: RoomId) : Confirmation
}

View file

@ -0,0 +1,51 @@
/*
* 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.leaveroom.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
class InternalLeaveRoomStateProvider : PreviewParameterProvider<InternalLeaveRoomState> {
override val values: Sequence<InternalLeaveRoomState>
get() = sequenceOf(
aLeaveRoomState(),
aLeaveRoomState(
leaveAction = Confirmation.Generic(roomId = A_ROOM_ID),
),
aLeaveRoomState(
leaveAction = Confirmation.PrivateRoom(roomId = A_ROOM_ID),
),
aLeaveRoomState(
leaveAction = Confirmation.LastUserInRoom(roomId = A_ROOM_ID),
),
aLeaveRoomState(
leaveAction = Confirmation.Dm(roomId = A_ROOM_ID),
),
aLeaveRoomState(
leaveAction = Confirmation.LastOwnerInRoom(roomId = A_ROOM_ID),
),
aLeaveRoomState(
leaveAction = AsyncAction.Loading,
),
aLeaveRoomState(
leaveAction = AsyncAction.Failure(RuntimeException("Something went wrong")),
),
)
}
private val A_ROOM_ID = RoomId("!aRoomId:aDomain")
fun aLeaveRoomState(
leaveAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (LeaveRoomEvent) -> Unit = {},
) = InternalLeaveRoomState(
leaveAction = leaveAction,
eventSink = eventSink,
)

View file

@ -14,15 +14,17 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.LeaveRoomState.Confirmation.Dm import io.element.android.libraries.architecture.AsyncAction
import io.element.android.features.leaveroom.api.LeaveRoomState.Confirmation.Generic
import io.element.android.features.leaveroom.api.LeaveRoomState.Confirmation.LastUserInRoom
import io.element.android.features.leaveroom.api.LeaveRoomState.Confirmation.PrivateRoom
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
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.core.RoomId
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.isDm import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.usersWithRole
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@ -35,71 +37,65 @@ class LeaveRoomPresenter @Inject constructor(
@Composable @Composable
override fun present(): LeaveRoomState { override fun present(): LeaveRoomState {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val confirmation = remember { mutableStateOf<LeaveRoomState.Confirmation>(LeaveRoomState.Confirmation.Hidden) } val leaveAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
val progress = remember { mutableStateOf<LeaveRoomState.Progress>(LeaveRoomState.Progress.Hidden) } return InternalLeaveRoomState(
val error = remember { mutableStateOf<LeaveRoomState.Error>(LeaveRoomState.Error.Hidden) } leaveAction = leaveAction.value,
return LeaveRoomState(
confirmation = confirmation.value,
progress = progress.value,
error = error.value,
) { event -> ) { event ->
when (event) { when (event) {
is LeaveRoomEvent.ShowConfirmation -> scope.launch(dispatchers.io) { is LeaveRoomEvent.LeaveRoom ->
showLeaveRoomAlert( if (event.needsConfirmation) {
matrixClient = client, scope.showLeaveRoomAlert(roomId = event.roomId, leaveAction = leaveAction)
roomId = event.roomId, } else {
confirmation = confirmation, scope.leaveRoom(roomId = event.roomId, leaveAction = leaveAction)
) }
} InternalLeaveRoomEvent.ResetState -> leaveAction.value = AsyncAction.Uninitialized
is LeaveRoomEvent.HideConfirmation -> confirmation.value = LeaveRoomState.Confirmation.Hidden
is LeaveRoomEvent.LeaveRoom -> scope.launch(dispatchers.io) {
client.leaveRoom(
roomId = event.roomId,
confirmation = confirmation,
progress = progress,
error = error,
)
}
is LeaveRoomEvent.HideError -> error.value = LeaveRoomState.Error.Hidden
} }
} }
} }
}
private suspend fun showLeaveRoomAlert( private fun CoroutineScope.showLeaveRoomAlert(
matrixClient: MatrixClient, roomId: RoomId,
roomId: RoomId, leaveAction: MutableState<AsyncAction<Unit>>,
confirmation: MutableState<LeaveRoomState.Confirmation>, ) = launch(dispatchers.io) {
) { client.getRoom(roomId)?.use { room ->
matrixClient.getRoom(roomId)?.use { room -> val roomInfo = room.roomInfoFlow.first()
val roomInfo = room.roomInfoFlow.first() leaveAction.value = when {
confirmation.value = when { roomInfo.isDm -> Confirmation.Dm(roomId)
roomInfo.isDm -> Dm(roomId) room.isLastOwner() && roomInfo.joinedMembersCount > 1L -> Confirmation.LastOwnerInRoom(roomId)
// If unknown, assume the room is private // If unknown, assume the room is private
roomInfo.isPublic == null || roomInfo.isPublic == false -> PrivateRoom(roomId) roomInfo.isPublic == null || roomInfo.isPublic == false -> Confirmation.PrivateRoom(roomId)
roomInfo.joinedMembersCount == 1L -> LastUserInRoom(roomId) roomInfo.joinedMembersCount == 1L -> Confirmation.LastUserInRoom(roomId)
else -> Generic(roomId) else -> Confirmation.Generic(roomId)
}
}
}
private fun CoroutineScope.leaveRoom(
roomId: RoomId,
leaveAction: MutableState<AsyncAction<Unit>>,
) = launch(dispatchers.io) {
leaveAction.runCatchingUpdatingState {
client.getRoom(roomId)!!.use { room ->
room
.leave()
.onFailure { Timber.e(it, "Error while leaving room ${room.roomId}") }
.getOrThrow()
}
}
}
private suspend fun BaseRoom.isLastOwner(): Boolean {
if (roomInfoFlow.value.isDm) {
// DMs are not owned by the user, so we can return false
return false
} else {
val hasPrivilegedCreatorRole = roomInfoFlow.value.privilegedCreatorRole
if (!hasPrivilegedCreatorRole) return false
val creators = usersWithRole(RoomMember.Role.Owner(isCreator = true)).first()
val superAdmins = usersWithRole(RoomMember.Role.Owner(isCreator = false)).first()
val owners = creators + superAdmins
return owners.size == 1 && owners.first().userId == sessionId
} }
} }
} }
private suspend fun MatrixClient.leaveRoom(
roomId: RoomId,
confirmation: MutableState<LeaveRoomState.Confirmation>,
progress: MutableState<LeaveRoomState.Progress>,
error: MutableState<LeaveRoomState.Error>,
) {
confirmation.value = LeaveRoomState.Confirmation.Hidden
progress.value = LeaveRoomState.Progress.Shown
getRoom(roomId)?.use { room ->
room.leave()
.onFailure {
Timber.e(it, "Error while leaving room ${room.roomId}")
error.value = LeaveRoomState.Error.Shown
}
}
progress.value = LeaveRoomState.Progress.Hidden
}

View file

@ -0,0 +1,149 @@
/*
* Copyright 2023, 2024 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.leaveroom.impl
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.R
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
@Suppress("LambdaParameterEventTrailing")
@Composable
fun LeaveRoomView(
state: InternalLeaveRoomState,
onSelectNewOwners: (RoomId) -> Unit,
) {
AsyncActionView(
state.leaveAction,
onSuccess = {
state.eventSink(InternalLeaveRoomEvent.ResetState)
},
onErrorDismiss = {
state.eventSink(InternalLeaveRoomEvent.ResetState)
},
confirmationDialog = { confirmation ->
if (confirmation is Confirmation) {
LeaveRoomConfirmationDialog(
confirmation = confirmation,
eventSink = state.eventSink,
onSelectNewOwners = onSelectNewOwners,
)
}
},
errorTitle = { stringResource(CommonStrings.common_something_went_wrong) },
errorMessage = { stringResource(CommonStrings.error_network_or_server_issue) },
progressDialog = { LeaveRoomProgressDialog() },
)
}
@Composable
private fun LeaveRoomConfirmationDialog(
confirmation: Confirmation,
eventSink: (LeaveRoomEvent) -> Unit,
onSelectNewOwners: (RoomId) -> Unit,
) {
val defaultOnSubmitClick = { roomId: RoomId -> { eventSink(LeaveRoomEvent.LeaveRoom(roomId, needsConfirmation = false)) } }
val defaultDismissAction = { eventSink(InternalLeaveRoomEvent.ResetState) }
when (confirmation) {
is Confirmation.Dm -> LeaveRoomConfirmationDialog(
text = stringResource(R.string.leave_room_alert_private_subtitle),
isDm = false,
onSubmitClick = defaultOnSubmitClick(confirmation.roomId),
onDismiss = defaultDismissAction,
)
is Confirmation.PrivateRoom -> LeaveRoomConfirmationDialog(
text = stringResource(R.string.leave_room_alert_private_subtitle),
isDm = false,
onSubmitClick = defaultOnSubmitClick(confirmation.roomId),
onDismiss = defaultDismissAction,
)
is Confirmation.LastUserInRoom -> LeaveRoomConfirmationDialog(
text = stringResource(R.string.leave_room_alert_empty_subtitle),
isDm = false,
onSubmitClick = defaultOnSubmitClick(confirmation.roomId),
onDismiss = defaultDismissAction,
)
is Confirmation.LastOwnerInRoom -> LeaveRoomConfirmationDialog(
title = stringResource(R.string.leave_room_alert_select_new_owner_title),
text = stringResource(R.string.leave_room_alert_select_new_owner_subtitle),
isDm = false,
submitText = stringResource(R.string.leave_room_alert_select_new_owner_action),
destructiveSubmit = true,
onSubmitClick = {
onSelectNewOwners(confirmation.roomId)
eventSink(InternalLeaveRoomEvent.ResetState)
},
onDismiss = defaultDismissAction,
)
is Confirmation.Generic -> LeaveRoomConfirmationDialog(
text = stringResource(R.string.leave_room_alert_subtitle),
isDm = false,
onSubmitClick = defaultOnSubmitClick(confirmation.roomId),
onDismiss = defaultDismissAction,
)
}
}
@Composable
private fun LeaveRoomConfirmationDialog(
isDm: Boolean,
text: String,
onSubmitClick: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
title: String = stringResource(if (isDm) CommonStrings.action_leave_conversation else CommonStrings.action_leave_room),
submitText: String = stringResource(CommonStrings.action_leave),
destructiveSubmit: Boolean = false,
) {
ConfirmationDialog(
title = title,
content = text,
submitText = submitText,
onSubmitClick = onSubmitClick,
onDismiss = onDismiss,
destructiveSubmit = destructiveSubmit,
modifier = modifier,
)
}
@Composable
private fun LeaveRoomProgressDialog(modifier: Modifier = Modifier) {
ProgressDialog(
text = stringResource(CommonStrings.common_leaving_room),
modifier = modifier,
)
}
@PreviewsDayNight
@Composable
internal fun LeaveRoomViewPreview(
@PreviewParameter(InternalLeaveRoomStateProvider::class) state: InternalLeaveRoomState
) = ElementPreview {
Box(
modifier = Modifier.size(300.dp, 300.dp),
propagateMinConstraints = true,
) {
LeaveRoomView(state = state, onSelectNewOwners = {})
}
}

View file

@ -12,7 +12,7 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.MatrixClient 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_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
@ -23,6 +23,8 @@ import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -36,15 +38,12 @@ class LeaveBaseRoomPresenterTest {
@Test @Test
fun `present - initial state hides all dialogs`() = runTest { fun `present - initial state hides all dialogs`() = runTest {
val presenter = createLeaveRoomPresenter() createLeaveRoomPresenter()
moleculeFlow(RecompositionMode.Immediate) { .stateFlow()
presenter.present() .test {
}.test { val initialState = awaitItem()
val initialState = awaitItem() assertThat(initialState.leaveAction).isEqualTo(AsyncAction.Uninitialized)
assertThat(initialState.confirmation).isEqualTo(LeaveRoomState.Confirmation.Hidden) }
assertThat(initialState.progress).isEqualTo(LeaveRoomState.Progress.Hidden)
assertThat(initialState.error).isEqualTo(LeaveRoomState.Error.Hidden)
}
} }
@Test @Test
@ -59,13 +58,11 @@ class LeaveBaseRoomPresenterTest {
) )
} }
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.stateFlow().test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID)) initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true))
val confirmationState = awaitItem() val confirmationState = awaitItem()
assertThat(confirmationState.confirmation).isEqualTo(LeaveRoomState.Confirmation.Generic(A_ROOM_ID)) assertThat(confirmationState.leaveAction).isEqualTo(Confirmation.Generic(A_ROOM_ID))
} }
} }
@ -81,13 +78,11 @@ class LeaveBaseRoomPresenterTest {
) )
} }
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.stateFlow().test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID)) initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true))
val confirmationState = awaitItem() val confirmationState = awaitItem()
assertThat(confirmationState.confirmation).isEqualTo(LeaveRoomState.Confirmation.PrivateRoom(A_ROOM_ID)) assertThat(confirmationState.leaveAction).isEqualTo(Confirmation.PrivateRoom(A_ROOM_ID))
} }
} }
@ -103,13 +98,11 @@ class LeaveBaseRoomPresenterTest {
) )
} }
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.stateFlow().test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID)) initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true))
val confirmationState = awaitItem() val confirmationState = awaitItem()
assertThat(confirmationState.confirmation).isEqualTo(LeaveRoomState.Confirmation.LastUserInRoom(A_ROOM_ID)) assertThat(confirmationState.leaveAction).isEqualTo(Confirmation.LastUserInRoom(A_ROOM_ID))
} }
} }
@ -125,13 +118,11 @@ class LeaveBaseRoomPresenterTest {
) )
} }
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.stateFlow().test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID)) initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true))
val confirmationState = awaitItem() val confirmationState = awaitItem()
assertThat(confirmationState.confirmation).isEqualTo(LeaveRoomState.Confirmation.Dm(A_ROOM_ID)) assertThat(confirmationState.leaveAction).isEqualTo(Confirmation.Dm(A_ROOM_ID))
} }
} }
@ -148,11 +139,9 @@ class LeaveBaseRoomPresenterTest {
) )
}, },
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.stateFlow().test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID)) initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = false))
advanceUntilIdle() advanceUntilIdle()
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
assert(leaveRoomLambda) assert(leaveRoomLambda)
@ -173,44 +162,19 @@ class LeaveBaseRoomPresenterTest {
) )
} }
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.stateFlow().test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID)) initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = false))
skipItems(1) // Skip show progress state val progressState = awaitItem()
assertThat(progressState.leaveAction).isEqualTo(AsyncAction.Loading)
val errorState = awaitItem() val errorState = awaitItem()
assertThat(errorState.error).isEqualTo(LeaveRoomState.Error.Shown) assertThat(errorState.leaveAction).isInstanceOf(AsyncAction.Failure::class.java)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@Test @Test
fun `present - show progress indicator while leaving a room`() = runTest { fun `present - reset state after error`() = runTest {
val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
result = FakeBaseRoom(
leaveRoomLambda = { Result.success(Unit) }
),
)
}
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID))
val progressState = awaitItem()
assertThat(progressState.progress).isEqualTo(LeaveRoomState.Progress.Shown)
val finalState = awaitItem()
assertThat(finalState.progress).isEqualTo(LeaveRoomState.Progress.Hidden)
}
}
@Test
fun `present - hide error hides the error`() = runTest {
val presenter = createLeaveRoomPresenter( val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply { client = FakeMatrixClient().apply {
givenGetRoomResult( givenGetRoomResult(
@ -221,20 +185,23 @@ class LeaveBaseRoomPresenterTest {
) )
} }
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.stateFlow().test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID)) initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = false))
skipItems(1) // Skip show progress state skipItems(1) // Skip show progress state
val errorState = awaitItem() val errorState = awaitItem()
assertThat(errorState.error).isEqualTo(LeaveRoomState.Error.Shown) assertThat(errorState.leaveAction).isInstanceOf(AsyncAction.Failure::class.java)
skipItems(1) // Skip hide progress state errorState.eventSink(InternalLeaveRoomEvent.ResetState)
errorState.eventSink(LeaveRoomEvent.HideError)
val hiddenErrorState = awaitItem() val hiddenErrorState = awaitItem()
assertThat(hiddenErrorState.error).isEqualTo(LeaveRoomState.Error.Hidden) assertThat(hiddenErrorState.leaveAction).isEqualTo(AsyncAction.Uninitialized)
} }
} }
private fun LeaveRoomPresenter.stateFlow(): Flow<InternalLeaveRoomState> {
return moleculeFlow(RecompositionMode.Immediate) {
present()
}.filterIsInstance(InternalLeaveRoomState::class)
}
} }
private fun TestScope.createLeaveRoomPresenter( private fun TestScope.createLeaveRoomPresenter(

View file

@ -55,6 +55,7 @@ dependencies {
implementation(projects.features.verifysession.api) implementation(projects.features.verifysession.api)
implementation(projects.features.reportroom.api) implementation(projects.features.reportroom.api)
implementation(projects.features.roommembermoderation.api) implementation(projects.features.roommembermoderation.api)
implementation(projects.features.changeroommemberroles.api)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)

View file

@ -8,7 +8,7 @@
package io.element.android.features.roomdetails.impl package io.element.android.features.roomdetails.impl
sealed interface RoomDetailsEvent { sealed interface RoomDetailsEvent {
data object LeaveRoom : RoomDetailsEvent data class LeaveRoom(val needsConfirmation: Boolean) : RoomDetailsEvent
data object MuteNotification : RoomDetailsEvent data object MuteNotification : RoomDetailsEvent
data object UnmuteNotification : RoomDetailsEvent data object UnmuteNotification : RoomDetailsEvent
data class CopyToClipboard(val text: String) : RoomDetailsEvent data class CopyToClipboard(val text: String) : RoomDetailsEvent

View file

@ -11,6 +11,8 @@ import android.os.Parcelable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
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
@ -25,6 +27,8 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.appconfig.LearnMoreConfig import io.element.android.appconfig.LearnMoreConfig
import io.element.android.features.call.api.CallType import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesListType
import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.poll.api.history.PollHistoryEntryPoint import io.element.android.features.poll.api.history.PollHistoryEntryPoint
@ -51,12 +55,15 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.BaseRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.verification.VerificationRequest import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
@ -65,7 +72,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val pollHistoryEntryPoint: PollHistoryEntryPoint, private val pollHistoryEntryPoint: PollHistoryEntryPoint,
private val elementCallEntryPoint: ElementCallEntryPoint, private val elementCallEntryPoint: ElementCallEntryPoint,
private val room: BaseRoom, private val room: JoinedRoom,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val messagesEntryPoint: MessagesEntryPoint, private val messagesEntryPoint: MessagesEntryPoint,
private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint, private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint,
@ -73,6 +80,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
private val mediaGalleryEntryPoint: MediaGalleryEntryPoint, private val mediaGalleryEntryPoint: MediaGalleryEntryPoint,
private val outgoingVerificationEntryPoint: OutgoingVerificationEntryPoint, private val outgoingVerificationEntryPoint: OutgoingVerificationEntryPoint,
private val reportRoomEntryPoint: ReportRoomEntryPoint, private val reportRoomEntryPoint: ReportRoomEntryPoint,
private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint,
) : BaseFlowNode<RoomDetailsFlowNode.NavTarget>( ) : BaseFlowNode<RoomDetailsFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().first().initialElement.toNavTarget(), initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().first().initialElement.toNavTarget(),
@ -132,6 +140,24 @@ class RoomDetailsFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data object ReportRoom : NavTarget data object ReportRoom : NavTarget
@Parcelize
data object SelectNewOwnersWhenLeaving : NavTarget
}
override fun onBuilt() {
super.onBuilt()
whenChildrenAttached { commonLifecycle: Lifecycle,
roomDetailsNode: RoomDetailsNode,
changeRoomMemberRolesNode: ChangeRoomMemberRolesEntryPoint.NodeProxy ->
commonLifecycle.coroutineScope.launch {
changeRoomMemberRolesNode.waitForRoleChanged()
withContext(NonCancellable) {
backstack.pop()
roomDetailsNode.onNewOwnersSelected()
}
}
}
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -198,6 +224,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
override fun openReportRoom() { override fun openReportRoom() {
backstack.push(NavTarget.ReportRoom) backstack.push(NavTarget.ReportRoom)
} }
override fun onSelectNewOwnersWhenLeaving() {
backstack.push(NavTarget.SelectNewOwnersWhenLeaving)
}
} }
createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback)) createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback))
} }
@ -330,7 +360,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
is NavTarget.VerifyUser -> { is NavTarget.VerifyUser -> {
val params = OutgoingVerificationEntryPoint.Params( val params = OutgoingVerificationEntryPoint.Params(
showDeviceVerifiedScreen = true, showDeviceVerifiedScreen = true,
verificationRequest = VerificationRequest.Outgoing.User(userId = navTarget.userId,) verificationRequest = VerificationRequest.Outgoing.User(userId = navTarget.userId)
) )
outgoingVerificationEntryPoint.nodeBuilder(this, buildContext) outgoingVerificationEntryPoint.nodeBuilder(this, buildContext)
.params(params) .params(params)
@ -352,6 +382,13 @@ class RoomDetailsFlowNode @AssistedInject constructor(
is NavTarget.ReportRoom -> { is NavTarget.ReportRoom -> {
reportRoomEntryPoint.createNode(this, buildContext, room.roomId) reportRoomEntryPoint.createNode(this, buildContext, room.roomId)
} }
is NavTarget.SelectNewOwnersWhenLeaving -> {
changeRoomMemberRolesEntryPoint.builder(this, buildContext)
.room(room)
.listType(ChangeRoomMemberRolesListType.SelectNewOwnersWhenLeaving)
.build()
}
} }
} }

View file

@ -9,6 +9,8 @@ package io.element.android.features.roomdetails.impl
import android.content.Context import android.content.Context
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 androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@ -21,7 +23,9 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.leaveroom.api.LeaveRoomRenderer
import io.element.android.libraries.androidutils.system.startSharePlainTextIntent import io.element.android.libraries.androidutils.system.startSharePlainTextIntent
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.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.BaseRoom import io.element.android.libraries.matrix.api.room.BaseRoom
@ -38,6 +42,7 @@ class RoomDetailsNode @AssistedInject constructor(
private val presenter: RoomDetailsPresenter, private val presenter: RoomDetailsPresenter,
private val room: BaseRoom, private val room: BaseRoom,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val leaveRoomRenderer: LeaveRoomRenderer,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin { interface Callback : Plugin {
fun openRoomMemberList() fun openRoomMemberList()
@ -54,9 +59,10 @@ class RoomDetailsNode @AssistedInject constructor(
fun openDmUserProfile(userId: UserId) fun openDmUserProfile(userId: UserId)
fun onJoinCall() fun onJoinCall()
fun openReportRoom() fun openReportRoom()
fun onSelectNewOwnersWhenLeaving()
} }
private val callbacks = plugins<Callback>() private val callback = plugins<Callback>().first()
init { init {
lifecycle.subscribe( lifecycle.subscribe(
@ -67,27 +73,27 @@ class RoomDetailsNode @AssistedInject constructor(
} }
private fun openRoomMemberList() { private fun openRoomMemberList() {
callbacks.forEach { it.openRoomMemberList() } callback.openRoomMemberList()
} }
private fun openRoomNotificationSettings() { private fun openRoomNotificationSettings() {
callbacks.forEach { it.openRoomNotificationSettings() } callback.openRoomNotificationSettings()
} }
private fun invitePeople() { private fun invitePeople() {
callbacks.forEach { it.openInviteMembers() } callback.openInviteMembers()
} }
private fun openPollHistory() { private fun openPollHistory() {
callbacks.forEach { it.openPollHistory() } callback.openPollHistory()
} }
private fun openMediaGallery() { private fun openMediaGallery() {
callbacks.forEach { it.openMediaGallery() } callback.openMediaGallery()
} }
private fun onJoinCall() { private fun onJoinCall() {
callbacks.forEach { it.onJoinCall() } callback.onJoinCall()
} }
private fun CoroutineScope.onShareRoom(context: Context) = launch { private fun CoroutineScope.onShareRoom(context: Context) = launch {
@ -106,41 +112,51 @@ class RoomDetailsNode @AssistedInject constructor(
} }
private fun onEditRoomDetails() { private fun onEditRoomDetails() {
callbacks.forEach { it.editRoomDetails() } callback.editRoomDetails()
} }
private fun openAvatarPreview(name: String, url: String) { private fun openAvatarPreview(name: String, url: String) {
callbacks.forEach { it.openAvatarPreview(name, url) } callback.openAvatarPreview(name, url)
} }
private fun openAdminSettings() { private fun openAdminSettings() {
callbacks.forEach { it.openAdminSettings() } callback.openAdminSettings()
} }
private fun openPinnedMessages() { private fun openPinnedMessages() {
callbacks.forEach { it.openPinnedMessagesList() } callback.openPinnedMessagesList()
} }
private fun openKnockRequestsLists() { private fun openKnockRequestsLists() {
callbacks.forEach { it.openKnockRequestsList() } callback.openKnockRequestsList()
} }
private fun openSecurityAndPrivacy() { private fun openSecurityAndPrivacy() {
callbacks.forEach { it.openSecurityAndPrivacy() } callback.openSecurityAndPrivacy()
} }
private fun onProfileClick(userId: UserId) { private fun onProfileClick(userId: UserId) {
callbacks.forEach { it.openDmUserProfile(userId) } callback.openDmUserProfile(userId)
} }
private fun onReportRoomClick() { private fun onReportRoomClick() {
callbacks.forEach { it.openReportRoom() } callback.openReportRoom()
}
private fun onSelectNewOwnersWhenLeaving() {
return callback.onSelectNewOwnersWhenLeaving()
}
private val stateFlow = launchMolecule { presenter.present() }
fun onNewOwnersSelected() {
stateFlow.value.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false))
} }
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val context = LocalContext.current val context = LocalContext.current
val state = presenter.present() val state by stateFlow.collectAsState()
fun onShareRoom() { fun onShareRoom() {
lifecycleScope.onShareRoom(context) lifecycleScope.onShareRoom(context)
@ -172,6 +188,13 @@ class RoomDetailsNode @AssistedInject constructor(
onSecurityAndPrivacyClick = ::openSecurityAndPrivacy, onSecurityAndPrivacyClick = ::openSecurityAndPrivacy,
onProfileClick = ::onProfileClick, onProfileClick = ::onProfileClick,
onReportRoomClick = ::onReportRoomClick, onReportRoomClick = ::onReportRoomClick,
leaveRoomView = {
leaveRoomRenderer.Render(
state = state.leaveRoomState,
onSelectNewOwners = { onSelectNewOwnersWhenLeaving() },
modifier = Modifier
)
}
) )
} }
} }

View file

@ -149,8 +149,9 @@ class RoomDetailsPresenter @Inject constructor(
fun handleEvents(event: RoomDetailsEvent) { fun handleEvents(event: RoomDetailsEvent) {
when (event) { when (event) {
RoomDetailsEvent.LeaveRoom -> is RoomDetailsEvent.LeaveRoom -> {
leaveRoomState.eventSink(LeaveRoomEvent.ShowConfirmation(room.roomId)) leaveRoomState.eventSink(LeaveRoomEvent.LeaveRoom(room.roomId, needsConfirmation = event.needsConfirmation))
}
RoomDetailsEvent.MuteNotification -> { RoomDetailsEvent.MuteNotification -> {
scope.launch(dispatchers.io) { scope.launch(dispatchers.io) {
client.notificationSettingsService().muteRoom(room.roomId) client.notificationSettingsService().muteRoom(room.roomId)

View file

@ -8,8 +8,8 @@
package io.element.android.features.roomdetails.impl package io.element.android.features.roomdetails.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.aLeaveRoomState
import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomcall.api.aStandByCallState import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roomdetails.impl.members.aRoomMember import io.element.android.features.roomdetails.impl.members.aRoomMember
@ -156,6 +156,12 @@ fun aRoomDetailsState(
eventSink = eventSink, eventSink = eventSink,
) )
internal fun aLeaveRoomState(
eventSink: (LeaveRoomEvent) -> Unit = {}
) = object : LeaveRoomState {
override val eventSink: (LeaveRoomEvent) -> Unit = eventSink
}
fun aRoomNotificationSettings( fun aRoomNotificationSettings(
mode: RoomNotificationMode = RoomNotificationMode.MUTE, mode: RoomNotificationMode = RoomNotificationMode.MUTE,
isDefault: Boolean = false, isDefault: Boolean = false,

View file

@ -38,7 +38,6 @@ import androidx.compose.ui.unit.dp
import im.vector.app.features.analytics.plan.Interaction import im.vector.app.features.analytics.plan.Interaction
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.compound.tokens.generated.CompoundIcons
import io.element.android.features.leaveroom.api.LeaveRoomView
import io.element.android.features.roomcall.api.hasPermissionToJoin import io.element.android.features.roomcall.api.hasPermissionToJoin
import io.element.android.features.userprofile.api.UserProfileVerificationState import io.element.android.features.userprofile.api.UserProfileVerificationState
import io.element.android.features.userprofile.shared.blockuser.BlockUserDialogs import io.element.android.features.userprofile.shared.blockuser.BlockUserDialogs
@ -112,6 +111,7 @@ fun RoomDetailsView(
onProfileClick: (UserId) -> Unit, onProfileClick: (UserId) -> Unit,
onReportRoomClick: () -> Unit, onReportRoomClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
leaveRoomView: @Composable () -> Unit,
) { ) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
Scaffold( Scaffold(
@ -131,7 +131,7 @@ fun RoomDetailsView(
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.consumeWindowInsets(padding) .consumeWindowInsets(padding)
) { ) {
LeaveRoomView(state = state.leaveRoomState) leaveRoomView()
when (state.roomType) { when (state.roomType) {
RoomDetailsType.Room -> { RoomDetailsType.Room -> {
@ -262,7 +262,7 @@ fun RoomDetailsView(
OtherActionsSection( OtherActionsSection(
canReportRoom = state.canReportRoom, canReportRoom = state.canReportRoom,
onReportRoomClick = onReportRoomClick, onReportRoomClick = onReportRoomClick,
onLeaveRoomClick = { state.eventSink(RoomDetailsEvent.LeaveRoom) } onLeaveRoomClick = { state.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true)) }
) )
if (state.showDebugInfo) { if (state.showDebugInfo) {
@ -776,5 +776,6 @@ private fun ContentToPreview(state: RoomDetailsState) {
onSecurityAndPrivacyClick = {}, onSecurityAndPrivacyClick = {},
onProfileClick = {}, onProfileClick = {},
onReportRoomClick = {}, onReportRoomClick = {},
leaveRoomView = {},
) )
} }

View file

@ -10,27 +10,34 @@ package io.element.android.features.roomdetails.impl.rolesandpermissions
import android.os.Parcelable 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.coroutineScope
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 com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles.ChangeRolesNode import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesListType
import io.element.android.features.roomdetails.impl.rolesandpermissions.permissions.ChangeRoomPermissionsNode import io.element.android.features.roomdetails.impl.rolesandpermissions.permissions.ChangeRoomPermissionsNode
import io.element.android.features.roomdetails.impl.rolesandpermissions.permissions.ChangeRoomPermissionsSection import io.element.android.features.roomdetails.impl.rolesandpermissions.permissions.ChangeRoomPermissionsSection
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
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.room.JoinedRoom
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
class RolesAndPermissionsFlowNode @AssistedInject constructor( class RolesAndPermissionsFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint,
private val joinedRoom: JoinedRoom,
) : BaseFlowNode<RolesAndPermissionsFlowNode.NavTarget>( ) : BaseFlowNode<RolesAndPermissionsFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.AdminSettings, initialElement = NavTarget.AdminSettings,
@ -53,6 +60,16 @@ class RolesAndPermissionsFlowNode @AssistedInject constructor(
data class ChangeRoomPermissions(val section: ChangeRoomPermissionsSection) : NavTarget data class ChangeRoomPermissions(val section: ChangeRoomPermissionsSection) : NavTarget
} }
override fun onBuilt() {
super.onBuilt()
whenChildAttached { lifecycle, node: ChangeRoomMemberRolesEntryPoint.NodeProxy ->
lifecycle.coroutineScope.launch {
node.waitForRoleChanged()
backstack.pop()
}
}
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
is NavTarget.AdminSettings -> { is NavTarget.AdminSettings -> {
@ -83,18 +100,16 @@ class RolesAndPermissionsFlowNode @AssistedInject constructor(
) )
} }
is NavTarget.AdminList -> { is NavTarget.AdminList -> {
val inputs = ChangeRolesNode.Inputs(ChangeRolesNode.ListType.Admins) changeRoomMemberRolesEntryPoint.builder(this, buildContext)
createNode<ChangeRolesNode>( .room(joinedRoom)
buildContext = buildContext, .listType(ChangeRoomMemberRolesListType.Admins)
plugins = listOf(inputs), .build()
)
} }
is NavTarget.ModeratorList -> { is NavTarget.ModeratorList -> {
val inputs = ChangeRolesNode.Inputs(ChangeRolesNode.ListType.Moderators) changeRoomMemberRolesEntryPoint.builder(this, buildContext)
createNode<ChangeRolesNode>( .room(joinedRoom)
buildContext = buildContext, .listType(ChangeRoomMemberRolesListType.Moderators)
plugins = listOf(inputs), .build()
)
} }
is NavTarget.ChangeRoomPermissions -> { is NavTarget.ChangeRoomPermissions -> {
val inputs = ChangeRoomPermissionsNode.Inputs(navTarget.section) val inputs = ChangeRoomPermissionsNode.Inputs(navTarget.section)

View file

@ -12,7 +12,6 @@ import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Interaction import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.aLeaveRoomState
import io.element.android.features.roomcall.api.aStandByCallState import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roomdetails.impl.members.aRoomMember import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
@ -548,8 +547,8 @@ class RoomDetailsPresenterTest {
dispatchers = testCoroutineDispatchers() dispatchers = testCoroutineDispatchers()
) )
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
awaitItem().eventSink(RoomDetailsEvent.LeaveRoom) awaitItem().eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
leaveRoomEventRecorder.assertSingle(LeaveRoomEvent.ShowConfirmation(room.roomId)) leaveRoomEventRecorder.assertSingle(LeaveRoomEvent.LeaveRoom(room.roomId, needsConfirmation = true))
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }

View file

@ -280,7 +280,7 @@ class RoomDetailsViewTest {
), ),
) )
rule.clickOn(R.string.screen_room_details_leave_room_title) rule.clickOn(R.string.screen_room_details_leave_room_title)
eventsRecorder.assertSingle(RoomDetailsEvent.LeaveRoom) eventsRecorder.assertSingle(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
} }
@Config(qualifiers = "h1500dp") @Config(qualifiers = "h1500dp")
@ -368,6 +368,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomD
onSecurityAndPrivacyClick = onSecurityAndPrivacyClick, onSecurityAndPrivacyClick = onSecurityAndPrivacyClick,
onProfileClick = onProfileClick, onProfileClick = onProfileClick,
onReportRoomClick = onReportRoomClick, onReportRoomClick = onReportRoomClick,
leaveRoomView = {},
) )
} }
} }

View file

@ -31,6 +31,7 @@ org.gradle.parallel=true
# Caching # Caching
org.gradle.caching=true org.gradle.caching=true
org.gradle.configuration-cache=true org.gradle.configuration-cache=true
org.gradle.configuration-cache.parallel=true
kotlin.incremental=true kotlin.incremental=true
# Dummy values for signing secrets / nightly # Dummy values for signing secrets / nightly

View file

@ -19,6 +19,7 @@ dependencies {
api(libs.dagger) api(libs.dagger)
api(libs.appyx.core) api(libs.appyx.core)
api(libs.androidx.lifecycle.runtime) api(libs.androidx.lifecycle.runtime)
api(libs.molecule.runtime)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)

View file

@ -0,0 +1,34 @@
/*
* 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(InternalComposeApi::class)
package io.element.android.libraries.architecture.appyx
import androidx.compose.runtime.Composable
import androidx.compose.runtime.InternalComposeApi
import androidx.compose.runtime.currentComposer
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.lifecycleScope
import app.cash.molecule.AndroidUiDispatcher
import app.cash.molecule.RecompositionMode
import app.cash.molecule.launchMolecule
import com.bumble.appyx.core.node.Node
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow
fun <State> Node.launchMolecule(body: @Composable () -> State): StateFlow<State> {
val scope = CoroutineScope(lifecycleScope.coroutineContext + AndroidUiDispatcher.Main)
return scope.launchMolecule(mode = RecompositionMode.ContextClock) {
currentComposer.startProviders(
values = arrayOf(LocalLifecycleOwner provides this),
)
val state = body()
currentComposer.endProviders()
state
}
}

View file

@ -9,21 +9,33 @@ package io.element.android.libraries.matrix.api.room.powerlevels
import io.element.android.libraries.matrix.api.room.BaseRoom import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.activeRoomMembers import io.element.android.libraries.matrix.api.room.activeRoomMembers
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
/** /**
* Return a flow of the list of active room members who have the given role. * Return a flow of the list of active room members who have the given role.
*/ */
fun BaseRoom.usersWithRole(role: RoomMember.Role): Flow<ImmutableList<RoomMember>> { fun BaseRoom.usersWithRole(role: RoomMember.Role): Flow<ImmutableList<RoomMember>> {
// Ensure the room members flow is ready
val readyMembersFlow = membersStateFlow
.onStart {
if (membersStateFlow.value is RoomMembersState.Unknown) {
updateMembers()
}
}
.filter { it is RoomMembersState.Ready }
return roomInfoFlow return roomInfoFlow
.map { roomInfo -> roomInfo.usersWithRole(role) } .map { roomInfo -> roomInfo.usersWithRole(role) }
.combine(membersStateFlow) { powerLevels, membersState -> .combine(readyMembersFlow) { powerLevels, membersState ->
membersState.activeRoomMembers() membersState.activeRoomMembers()
.filter { powerLevels.contains(it.userId) } .filter { powerLevels.contains(it.userId) }
.toPersistentList() .toPersistentList()

View file

@ -10,6 +10,7 @@ package io.element.android.libraries.matrix.test.room
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
import kotlinx.collections.immutable.persistentListOf
fun aRoomMember( fun aRoomMember(
userId: UserId = UserId("@alice:server.org"), userId: UserId = UserId("@alice:server.org"),
@ -34,3 +35,31 @@ fun aRoomMember(
role = role, role = role,
membershipChangeReason = membershipChangeReason, membershipChangeReason = membershipChangeReason,
) )
fun aRoomMemberList() = persistentListOf(
anAlice(),
aBob(),
aRoomMember(UserId("@carol:server.org"), "Carol"),
aRoomMember(UserId("@david:server.org"), "David"),
aRoomMember(UserId("@eve:server.org"), "Eve"),
aRoomMember(UserId("@justin:server.org"), "Justin"),
aRoomMember(UserId("@mallory:server.org"), "Mallory"),
aRoomMember(UserId("@susie:server.org"), "Susie"),
aVictor(),
aWalter(),
)
fun anAlice() = aRoomMember(UserId("@alice:server.org"), "Alice", role = RoomMember.Role.Admin)
fun aBob() = aRoomMember(UserId("@bob:server.org"), "Bob", role = RoomMember.Role.Moderator)
fun aVictor() = aRoomMember(
UserId("@victor:server.org"),
"Victor",
membership = RoomMembershipState.INVITE
)
fun aWalter() = aRoomMember(
UserId("@walter:server.org"),
"Walter",
membership = RoomMembershipState.INVITE
)

View file

@ -0,0 +1,28 @@
/*
* 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.libraries.matrix.ui.room
import io.element.android.libraries.matrix.api.room.RoomMember
import java.text.Collator
// Comparator used to sort room members by power level (descending) and then by name (ascending)
class PowerLevelRoomMemberComparator : Comparator<RoomMember> {
// Used to simplify and compare unicode and ASCII chars (á == a)
private val collator = Collator.getInstance().apply {
decomposition = Collator.CANONICAL_DECOMPOSITION
}
override fun compare(o1: RoomMember, o2: RoomMember): Int {
return when {
o1.powerLevel > o2.powerLevel -> return -1
o1.powerLevel < o2.powerLevel -> return 1
else -> {
collator.compare(o1.sortingName(), o2.sortingName())
}
}
}
}

View file

@ -0,0 +1,21 @@
/*
* Copyright 2022-2024 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.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.previewutils"
dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.api)
implementation(libs.kotlinx.collections.immutable)
}
}

View file

@ -0,0 +1,65 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.previewutils.room
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import kotlinx.collections.immutable.persistentListOf
fun aRoomMember(
userId: UserId = UserId("@alice:server.org"),
displayName: String? = null,
avatarUrl: String? = null,
membership: RoomMembershipState = RoomMembershipState.JOIN,
isNameAmbiguous: Boolean = false,
powerLevel: Long = 0L,
normalizedPowerLevel: Long = 0L,
isIgnored: Boolean = false,
role: RoomMember.Role = RoomMember.Role.User,
membershipChangeReason: String? = null,
) = RoomMember(
userId = userId,
displayName = displayName,
avatarUrl = avatarUrl,
membership = membership,
isNameAmbiguous = isNameAmbiguous,
powerLevel = powerLevel,
normalizedPowerLevel = normalizedPowerLevel,
isIgnored = isIgnored,
role = role,
membershipChangeReason = membershipChangeReason,
)
fun aRoomMemberList() = persistentListOf(
anAlice(),
aBob(),
aRoomMember(UserId("@carol:server.org"), "Carol"),
aRoomMember(UserId("@david:server.org"), "David"),
aRoomMember(UserId("@eve:server.org"), "Eve"),
aRoomMember(UserId("@justin:server.org"), "Justin"),
aRoomMember(UserId("@mallory:server.org"), "Mallory"),
aRoomMember(UserId("@susie:server.org"), "Susie"),
aVictor(),
aWalter(),
)
fun anAlice() = aRoomMember(UserId("@alice:server.org"), "Alice", role = RoomMember.Role.Admin)
fun aBob() = aRoomMember(UserId("@bob:server.org"), "Bob", role = RoomMember.Role.Moderator)
fun aVictor() = aRoomMember(
UserId("@victor:server.org"),
"Victor",
membership = RoomMembershipState.INVITE
)
fun aWalter() = aRoomMember(
UserId("@walter:server.org"),
"Walter",
membership = RoomMembershipState.INVITE
)

View file

@ -149,6 +149,7 @@ class KonsistClassNameTest {
"Enterprise", "Enterprise",
"Fdroid", "Fdroid",
"FileExtensionExtractor", "FileExtensionExtractor",
"Internal",
"LiveMediaTimeline", "LiveMediaTimeline",
"KeyStore", "KeyStore",
"Matrix", "Matrix",

View file

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

View file

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

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more