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("BSD-2-Clause")
allow("BSD-3-Clause")
allow("EPL-1.0")
allowUrl("https://opensource.org/licenses/MIT")
allowUrl("https://developer.android.com/studio/terms.html")
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -28,6 +29,7 @@ class LoggedInEventProcessor @Inject constructor(
fun observeEvents(coroutineScope: CoroutineScope) {
observingJob = roomMembershipObserver.updates
.filter { !it.isUserInRoom }
.distinctUntilChanged()
.onEach {
when (it.change) {
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.
*/
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

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
* 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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
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.AssistedInject
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.appyx.launchMolecule
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.parcelize.Parcelize
import kotlinx.coroutines.flow.first
@ContributesNode(RoomScope::class)
class ChangeRolesNode @AssistedInject constructor(
@@ -28,31 +31,30 @@ class ChangeRolesNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>,
presenterFactory: ChangeRolesPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
sealed interface ListType : Parcelable {
@Parcelize
data object Admins : ListType
@Parcelize
data object Moderators : ListType
}
@Parcelize
data class Inputs(
val listType: ListType,
) : NodeInputs, Parcelable
val listType: ChangeRoomMemberRolesListType,
) : NodeInputs
private val inputs: Inputs = inputs()
private val presenter = presenterFactory.run {
val role = when (inputs.listType) {
is ListType.Admins -> RoomMember.Role.Admin
is ListType.Moderators -> RoomMember.Role.Moderator
ChangeRoomMemberRolesListType.Admins -> RoomMember.Role.Admin
ChangeRoomMemberRolesListType.Moderators -> RoomMember.Role.Moderator
ChangeRoomMemberRolesListType.SelectNewOwnersWhenLeaving -> RoomMember.Role.Owner(isCreator = false)
}
create(role)
}
private val stateFlow = launchMolecule { presenter.present() }
suspend fun waitForRoleChanged() {
stateFlow.first { it.savingState.isSuccess() }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val state by stateFlow.collectAsState()
ChangeRolesView(
modifier = modifier,
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
* 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.LaunchedEffect
@@ -22,9 +22,6 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
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.Presenter
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.user.MatrixUser
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 kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.PersistentList
@@ -136,8 +134,9 @@ class ChangeRolesPresenter @AssistedInject constructor(
val isModifyingAdmins = role == RoomMember.Role.Admin
val hasChanges = selectedUsers != usersWithRole
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 {
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.
*/
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.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList

View File

@@ -5,11 +5,9 @@
* 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 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.designsystem.theme.components.SearchBarResultState
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.ui.components.aMatrixUser
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.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@@ -44,6 +44,7 @@ class ChangeRolesStateProvider : PreviewParameterProvider<ChangeRolesState> {
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Success(Unit)),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Failure(Exception("boom"))),
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
* 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.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.unit.dp
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.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.async.AsyncActionView
@@ -87,7 +86,6 @@ fun ChangeRolesView(
BackHandler(enabled = !state.isSearchActive) {
state.eventSink(ChangeRolesEvent.Exit)
}
Box(modifier = modifier) {
Scaffold(
modifier = Modifier
@@ -97,9 +95,10 @@ fun ChangeRolesView(
AnimatedVisibility(visible = !state.isSearchActive) {
TopAppBar(
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.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 = {
BackButton(onClick = { state.eventSink(ChangeRolesEvent.Exit) })
@@ -188,14 +187,26 @@ fun ChangeRolesView(
when (state.savingState) {
is AsyncAction.Confirming -> {
if (state.role == RoomMember.Role.Admin) {
// Confirm adding new admins dialogs
ConfirmationDialog(
title = stringResource(R.string.screen_room_change_role_confirm_add_admin_title),
content = stringResource(R.string.screen_room_change_role_confirm_add_admin_description),
onSubmitClick = { state.eventSink(ChangeRolesEvent.Save) },
onDismiss = { state.eventSink(ChangeRolesEvent.ClearError) }
)
when (state.role) {
is RoomMember.Role.Owner -> {
ConfirmationDialog(
title = stringResource(R.string.screen_room_change_role_confirm_change_owners_title),
content = stringResource(R.string.screen_room_change_role_confirm_change_owners_description),
submitText = stringResource(CommonStrings.action_continue),
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 -> {

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
* 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.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
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.core.coroutine.CoroutineDispatchers
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.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.defaultRoomPowerLevelValues
import io.element.android.libraries.previewutils.room.aRoomMemberList
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.collections.immutable.toPersistentMap
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.collections.plus
class ChangeRolesPresenterTest {
@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
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()
@@ -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(
role: RoomMember.Role = RoomMember.Role.Admin,
room: FakeJoinedRoom = FakeJoinedRoom(),
room: FakeJoinedRoom = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {})),
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
): 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
* 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.compose.ui.test.junit4.AndroidComposeTestRule
@@ -54,20 +54,6 @@ class ChangeRolesViewTest {
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
fun `back key - with search active toggles the search`() {
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
@@ -192,6 +178,23 @@ class ChangeRolesViewTest {
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
fun `save confirmation dialog - cancel removes the dialog`() {
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()

View File

@@ -5,7 +5,7 @@
* 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 io.element.android.libraries.matrix.api.room.RoomMember

View File

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

View File

@@ -11,7 +11,11 @@ import android.app.Activity
import android.os.Parcelable
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.modality.BuildContext
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.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
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.impl.components.RoomListMenuAction
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.acceptdecline.AcceptDeclineInviteView
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.reportroom.api.ReportRoomEntryPoint
import io.element.android.libraries.architecture.BackstackView
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.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
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
@ContributesNode(SessionScope::class)
class HomeFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val matrixClient: MatrixClient,
private val presenter: HomePresenter,
private val inviteFriendsUseCase: InviteFriendsUseCase,
private val analyticsService: AnalyticsService,
@@ -51,6 +67,8 @@ class HomeFlowNode @AssistedInject constructor(
private val directLogoutView: DirectLogoutView,
private val reportRoomEntryPoint: ReportRoomEntryPoint,
private val declineInviteAndBlockUserEntryPoint: DeclineInviteAndBlockEntryPoint,
private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint,
private val leaveRoomRenderer: LeaveRoomRenderer,
) : BaseFlowNode<HomeFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
@@ -59,12 +77,25 @@ class HomeFlowNode @AssistedInject constructor(
buildContext = buildContext,
plugins = plugins
) {
init {
private val stateFlow = launchMolecule { presenter.present() }
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
onResume = {
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 {
@@ -76,6 +107,9 @@ class HomeFlowNode @AssistedInject constructor(
@Parcelize
data class DeclineInviteAndBlockUser(val inviteData: InviteData) : NavTarget
@Parcelize
data class SelectNewOwnersWhenLeavingRoom(val roomId: RoomId) : NavTarget
}
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 {
return node(buildContext) { modifier ->
val state = presenter.present()
val state by stateFlow.collectAsState()
val activity = requireNotNull(LocalActivity.current)
HomeView(
homeState = state,
onRoomClick = this::onRoomClick,
@@ -138,15 +179,22 @@ class HomeFlowNode @AssistedInject constructor(
onReportRoomClick = this::onReportRoomClick,
onDeclineInviteAndBlockUser = this::onDeclineInviteAndBlockUserClick,
modifier = modifier,
) {
acceptDeclineInviteView.Render(
state = state.roomListState.acceptDeclineInviteState,
onAcceptInviteSuccess = this::onRoomClick,
onDeclineInviteSuccess = { },
modifier = Modifier
)
}
acceptDeclineInviteView = {
acceptDeclineInviteView.Render(
state = state.roomListState.acceptDeclineInviteState,
onAcceptInviteSuccess = this::onRoomClick,
onDeclineInviteSuccess = { },
modifier = Modifier
)
},
leaveRoomView = {
leaveRoomRenderer.Render(
state = state.roomListState.leaveRoomState,
onSelectNewOwners = this::onSelectNewOwnersWhenLeavingRoom,
modifier = Modifier
)
}
)
directLogoutView.Render(state.directLogoutState)
}
}
@@ -160,6 +208,13 @@ class HomeFlowNode @AssistedInject constructor(
return when (navTarget) {
is NavTarget.ReportRoom -> reportRoomEntryPoint.createNode(this, buildContext, navTarget.roomId)
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)
}
}

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.RoomListState
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.libraries.androidutils.throttler.FirstThrottler
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -78,8 +77,9 @@ fun HomeView(
onMenuActionClick: (RoomListMenuAction) -> Unit,
onReportRoomClick: (roomId: RoomId) -> Unit,
onDeclineInviteAndBlockUser: (roomSummary: RoomListRoomSummary) -> Unit,
modifier: Modifier = Modifier,
acceptDeclineInviteView: @Composable () -> Unit,
modifier: Modifier = Modifier,
leaveRoomView: @Composable () -> Unit,
) {
val state: RoomListState = homeState.roomListState
val coroutineScope = rememberCoroutineScope()
@@ -108,7 +108,7 @@ fun HomeView(
)
}
LeaveRoomView(state = state.leaveRoomState)
leaveRoomView()
HomeScaffold(
state = homeState,
@@ -304,5 +304,6 @@ internal fun HomeViewPreview(@PreviewParameter(HomeStateProvider::class) state:
onMenuActionClick = {},
onDeclineInviteAndBlockUser = {},
acceptDeclineInviteView = {},
leaveRoomView = {}
)
}

View File

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

View File

@@ -24,7 +24,7 @@ sealed interface RoomListEvents {
sealed interface ContextMenuEvents : RoomListEvents
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 MarkAsUnread(val roomId: RoomId) : 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.DeclineInvite
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.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
@@ -127,7 +127,9 @@ class RoomListPresenter @Inject constructor(
is RoomListEvents.HideContextMenu -> {
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.MarkAsRead -> coroutineScope.markAsRead(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.invite.api.acceptdecline.AcceptDeclineInviteEvents
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.aLeaveRoomState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@@ -70,6 +70,12 @@ internal fun aRoomListState(
eventSink = eventSink,
)
internal fun aLeaveRoomState(
eventSink: (LeaveRoomEvent) -> Unit = {}
) = object : LeaveRoomState {
override val eventSink: (LeaveRoomEvent) -> Unit = eventSink
}
internal fun anAcceptDeclineInviteState(
acceptAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
declineAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,

View File

@@ -73,7 +73,7 @@ class RoomListContextMenuTest {
eventsRecorder.assertList(
listOf(
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.leaveroom.api.LeaveRoomEvent
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.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
@@ -319,8 +318,8 @@ class RoomListPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(RoomListEvents.LeaveRoom(A_ROOM_ID))
leaveRoomEventsRecorder.assertSingle(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID))
initialState.eventSink(RoomListEvents.LeaveRoom(A_ROOM_ID, needsConfirmation = true))
leaveRoomEventsRecorder.assertSingle(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true))
cancelAndIgnoreRemainingEvents()
}
}

View File

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

View File

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

View File

@@ -35,9 +35,12 @@ fun AcceptDeclineInviteView(
Box(modifier = modifier) {
AsyncActionView(
async = state.acceptAction,
onSuccess = onAcceptInviteSuccess,
onSuccess = { roomId ->
state.eventSink(InternalAcceptDeclineInviteEvents.ClearAcceptActionState)
onAcceptInviteSuccess(roomId)
},
onErrorDismiss = {
state.eventSink(InternalAcceptDeclineInviteEvents.DismissAcceptError)
state.eventSink(InternalAcceptDeclineInviteEvents.ClearAcceptActionState)
},
errorTitle = {
stringResource(CommonStrings.common_something_went_wrong)
@@ -52,9 +55,12 @@ fun AcceptDeclineInviteView(
)
AsyncActionView(
async = state.declineAction,
onSuccess = onDeclineInviteSuccess,
onSuccess = { roomId ->
state.eventSink(InternalAcceptDeclineInviteEvents.ClearDeclineActionState)
onDeclineInviteSuccess(roomId)
},
onErrorDismiss = {
state.eventSink(InternalAcceptDeclineInviteEvents.DismissDeclineError)
state.eventSink(InternalAcceptDeclineInviteEvents.ClearDeclineActionState)
},
errorTitle = {
stringResource(CommonStrings.common_something_went_wrong)
@@ -78,7 +84,7 @@ fun AcceptDeclineInviteView(
)
},
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
sealed interface InternalAcceptDeclineInviteEvents : AcceptDeclineInviteEvents {
data object CancelDeclineInvite : InternalAcceptDeclineInviteEvents
data object DismissAcceptError : InternalAcceptDeclineInviteEvents
data object DismissDeclineError : InternalAcceptDeclineInviteEvents
data object ClearAcceptActionState : InternalAcceptDeclineInviteEvents
data object ClearDeclineActionState : InternalAcceptDeclineInviteEvents
}

View File

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

View File

@@ -14,7 +14,5 @@ android {
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
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
sealed interface LeaveRoomEvent {
data class ShowConfirmation(val roomId: RoomId) : LeaveRoomEvent
data object HideConfirmation : LeaveRoomEvent
data class LeaveRoom(val roomId: RoomId) : LeaveRoomEvent
data object HideError : LeaveRoomEvent
interface LeaveRoomEvent {
data class LeaveRoom(val roomId: RoomId, val needsConfirmation: Boolean) : 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
import io.element.android.libraries.matrix.api.core.RoomId
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
}
interface LeaveRoomState {
val eventSink: (LeaveRoomEvent) -> Unit
}

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()
dependencies {
api(projects.features.leaveroom.api)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
api(projects.features.leaveroom.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
testImplementation(libs.test.junit)
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 io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.LeaveRoomState.Confirmation.Dm
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.AsyncAction
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.matrix.api.MatrixClient
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.powerlevels.usersWithRole
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import timber.log.Timber
@@ -35,71 +37,65 @@ class LeaveRoomPresenter @Inject constructor(
@Composable
override fun present(): LeaveRoomState {
val scope = rememberCoroutineScope()
val confirmation = remember { mutableStateOf<LeaveRoomState.Confirmation>(LeaveRoomState.Confirmation.Hidden) }
val progress = remember { mutableStateOf<LeaveRoomState.Progress>(LeaveRoomState.Progress.Hidden) }
val error = remember { mutableStateOf<LeaveRoomState.Error>(LeaveRoomState.Error.Hidden) }
return LeaveRoomState(
confirmation = confirmation.value,
progress = progress.value,
error = error.value,
val leaveAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
return InternalLeaveRoomState(
leaveAction = leaveAction.value,
) { event ->
when (event) {
is LeaveRoomEvent.ShowConfirmation -> scope.launch(dispatchers.io) {
showLeaveRoomAlert(
matrixClient = client,
roomId = event.roomId,
confirmation = confirmation,
)
}
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
is LeaveRoomEvent.LeaveRoom ->
if (event.needsConfirmation) {
scope.showLeaveRoomAlert(roomId = event.roomId, leaveAction = leaveAction)
} else {
scope.leaveRoom(roomId = event.roomId, leaveAction = leaveAction)
}
InternalLeaveRoomEvent.ResetState -> leaveAction.value = AsyncAction.Uninitialized
}
}
}
}
private suspend fun showLeaveRoomAlert(
matrixClient: MatrixClient,
roomId: RoomId,
confirmation: MutableState<LeaveRoomState.Confirmation>,
) {
matrixClient.getRoom(roomId)?.use { room ->
val roomInfo = room.roomInfoFlow.first()
confirmation.value = when {
roomInfo.isDm -> Dm(roomId)
// If unknown, assume the room is private
roomInfo.isPublic == null || roomInfo.isPublic == false -> PrivateRoom(roomId)
roomInfo.joinedMembersCount == 1L -> LastUserInRoom(roomId)
else -> Generic(roomId)
private fun CoroutineScope.showLeaveRoomAlert(
roomId: RoomId,
leaveAction: MutableState<AsyncAction<Unit>>,
) = launch(dispatchers.io) {
client.getRoom(roomId)?.use { room ->
val roomInfo = room.roomInfoFlow.first()
leaveAction.value = when {
roomInfo.isDm -> Confirmation.Dm(roomId)
room.isLastOwner() && roomInfo.joinedMembersCount > 1L -> Confirmation.LastOwnerInRoom(roomId)
// If unknown, assume the room is private
roomInfo.isPublic == null || roomInfo.isPublic == false -> Confirmation.PrivateRoom(roomId)
roomInfo.joinedMembersCount == 1L -> Confirmation.LastUserInRoom(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 com.google.common.truth.Truth.assertThat
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.test.A_ROOM_ID
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.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
@@ -36,15 +38,12 @@ class LeaveBaseRoomPresenterTest {
@Test
fun `present - initial state hides all dialogs`() = runTest {
val presenter = createLeaveRoomPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.confirmation).isEqualTo(LeaveRoomState.Confirmation.Hidden)
assertThat(initialState.progress).isEqualTo(LeaveRoomState.Progress.Hidden)
assertThat(initialState.error).isEqualTo(LeaveRoomState.Error.Hidden)
}
createLeaveRoomPresenter()
.stateFlow()
.test {
val initialState = awaitItem()
assertThat(initialState.leaveAction).isEqualTo(AsyncAction.Uninitialized)
}
}
@Test
@@ -59,13 +58,11 @@ class LeaveBaseRoomPresenterTest {
)
}
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.stateFlow().test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID))
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true))
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.present()
}.test {
presenter.stateFlow().test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID))
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true))
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.present()
}.test {
presenter.stateFlow().test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID))
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true))
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.present()
}.test {
presenter.stateFlow().test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID))
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true))
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.present()
}.test {
presenter.stateFlow().test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID))
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = false))
advanceUntilIdle()
cancelAndIgnoreRemainingEvents()
assert(leaveRoomLambda)
@@ -173,44 +162,19 @@ class LeaveBaseRoomPresenterTest {
)
}
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.stateFlow().test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID))
skipItems(1) // Skip show progress state
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = false))
val progressState = awaitItem()
assertThat(progressState.leaveAction).isEqualTo(AsyncAction.Loading)
val errorState = awaitItem()
assertThat(errorState.error).isEqualTo(LeaveRoomState.Error.Shown)
assertThat(errorState.leaveAction).isInstanceOf(AsyncAction.Failure::class.java)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - show progress indicator while leaving a room`() = 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 {
fun `present - reset state after error`() = runTest {
val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
@@ -221,20 +185,23 @@ class LeaveBaseRoomPresenterTest {
)
}
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.stateFlow().test {
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 errorState = awaitItem()
assertThat(errorState.error).isEqualTo(LeaveRoomState.Error.Shown)
skipItems(1) // Skip hide progress state
errorState.eventSink(LeaveRoomEvent.HideError)
assertThat(errorState.leaveAction).isInstanceOf(AsyncAction.Failure::class.java)
errorState.eventSink(InternalLeaveRoomEvent.ResetState)
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(

View File

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

View File

@@ -8,7 +8,7 @@
package io.element.android.features.roomdetails.impl
sealed interface RoomDetailsEvent {
data object LeaveRoom : RoomDetailsEvent
data class LeaveRoom(val needsConfirmation: Boolean) : RoomDetailsEvent
data object MuteNotification : RoomDetailsEvent
data object UnmuteNotification : 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.mutableStateOf
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.node.Node
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.features.call.api.CallType
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.messages.api.MessagesEntryPoint
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.toRoomIdOrAlias
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.mediaviewer.api.MediaGalleryEntryPoint
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.services.analytics.api.AnalyticsService
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
@ContributesNode(RoomScope::class)
@@ -65,7 +72,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>,
private val pollHistoryEntryPoint: PollHistoryEntryPoint,
private val elementCallEntryPoint: ElementCallEntryPoint,
private val room: BaseRoom,
private val room: JoinedRoom,
private val analyticsService: AnalyticsService,
private val messagesEntryPoint: MessagesEntryPoint,
private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint,
@@ -73,6 +80,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
private val mediaGalleryEntryPoint: MediaGalleryEntryPoint,
private val outgoingVerificationEntryPoint: OutgoingVerificationEntryPoint,
private val reportRoomEntryPoint: ReportRoomEntryPoint,
private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint,
) : BaseFlowNode<RoomDetailsFlowNode.NavTarget>(
backstack = BackStack(
initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().first().initialElement.toNavTarget(),
@@ -132,6 +140,24 @@ class RoomDetailsFlowNode @AssistedInject constructor(
@Parcelize
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 {
@@ -198,6 +224,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
override fun openReportRoom() {
backstack.push(NavTarget.ReportRoom)
}
override fun onSelectNewOwnersWhenLeaving() {
backstack.push(NavTarget.SelectNewOwnersWhenLeaving)
}
}
createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback))
}
@@ -330,7 +360,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
is NavTarget.VerifyUser -> {
val params = OutgoingVerificationEntryPoint.Params(
showDeviceVerifiedScreen = true,
verificationRequest = VerificationRequest.Outgoing.User(userId = navTarget.userId,)
verificationRequest = VerificationRequest.Outgoing.User(userId = navTarget.userId)
)
outgoingVerificationEntryPoint.nodeBuilder(this, buildContext)
.params(params)
@@ -352,6 +382,13 @@ class RoomDetailsFlowNode @AssistedInject constructor(
is NavTarget.ReportRoom -> {
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 androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.lifecycleScope
@@ -21,7 +23,9 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
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.architecture.appyx.launchMolecule
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.BaseRoom
@@ -38,6 +42,7 @@ class RoomDetailsNode @AssistedInject constructor(
private val presenter: RoomDetailsPresenter,
private val room: BaseRoom,
private val analyticsService: AnalyticsService,
private val leaveRoomRenderer: LeaveRoomRenderer,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun openRoomMemberList()
@@ -54,9 +59,10 @@ class RoomDetailsNode @AssistedInject constructor(
fun openDmUserProfile(userId: UserId)
fun onJoinCall()
fun openReportRoom()
fun onSelectNewOwnersWhenLeaving()
}
private val callbacks = plugins<Callback>()
private val callback = plugins<Callback>().first()
init {
lifecycle.subscribe(
@@ -67,27 +73,27 @@ class RoomDetailsNode @AssistedInject constructor(
}
private fun openRoomMemberList() {
callbacks.forEach { it.openRoomMemberList() }
callback.openRoomMemberList()
}
private fun openRoomNotificationSettings() {
callbacks.forEach { it.openRoomNotificationSettings() }
callback.openRoomNotificationSettings()
}
private fun invitePeople() {
callbacks.forEach { it.openInviteMembers() }
callback.openInviteMembers()
}
private fun openPollHistory() {
callbacks.forEach { it.openPollHistory() }
callback.openPollHistory()
}
private fun openMediaGallery() {
callbacks.forEach { it.openMediaGallery() }
callback.openMediaGallery()
}
private fun onJoinCall() {
callbacks.forEach { it.onJoinCall() }
callback.onJoinCall()
}
private fun CoroutineScope.onShareRoom(context: Context) = launch {
@@ -106,41 +112,51 @@ class RoomDetailsNode @AssistedInject constructor(
}
private fun onEditRoomDetails() {
callbacks.forEach { it.editRoomDetails() }
callback.editRoomDetails()
}
private fun openAvatarPreview(name: String, url: String) {
callbacks.forEach { it.openAvatarPreview(name, url) }
callback.openAvatarPreview(name, url)
}
private fun openAdminSettings() {
callbacks.forEach { it.openAdminSettings() }
callback.openAdminSettings()
}
private fun openPinnedMessages() {
callbacks.forEach { it.openPinnedMessagesList() }
callback.openPinnedMessagesList()
}
private fun openKnockRequestsLists() {
callbacks.forEach { it.openKnockRequestsList() }
callback.openKnockRequestsList()
}
private fun openSecurityAndPrivacy() {
callbacks.forEach { it.openSecurityAndPrivacy() }
callback.openSecurityAndPrivacy()
}
private fun onProfileClick(userId: UserId) {
callbacks.forEach { it.openDmUserProfile(userId) }
callback.openDmUserProfile(userId)
}
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
override fun View(modifier: Modifier) {
val context = LocalContext.current
val state = presenter.present()
val state by stateFlow.collectAsState()
fun onShareRoom() {
lifecycleScope.onShareRoom(context)
@@ -172,6 +188,13 @@ class RoomDetailsNode @AssistedInject constructor(
onSecurityAndPrivacyClick = ::openSecurityAndPrivacy,
onProfileClick = ::onProfileClick,
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) {
when (event) {
RoomDetailsEvent.LeaveRoom ->
leaveRoomState.eventSink(LeaveRoomEvent.ShowConfirmation(room.roomId))
is RoomDetailsEvent.LeaveRoom -> {
leaveRoomState.eventSink(LeaveRoomEvent.LeaveRoom(room.roomId, needsConfirmation = event.needsConfirmation))
}
RoomDetailsEvent.MuteNotification -> {
scope.launch(dispatchers.io) {
client.notificationSettingsService().muteRoom(room.roomId)

View File

@@ -8,8 +8,8 @@
package io.element.android.features.roomdetails.impl
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.aLeaveRoomState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roomdetails.impl.members.aRoomMember
@@ -156,6 +156,12 @@ fun aRoomDetailsState(
eventSink = eventSink,
)
internal fun aLeaveRoomState(
eventSink: (LeaveRoomEvent) -> Unit = {}
) = object : LeaveRoomState {
override val eventSink: (LeaveRoomEvent) -> Unit = eventSink
}
fun aRoomNotificationSettings(
mode: RoomNotificationMode = RoomNotificationMode.MUTE,
isDefault: Boolean = false,

View File

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

View File

@@ -10,27 +10,34 @@ package io.element.android.features.roomdetails.impl.rolesandpermissions
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.coroutineScope
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles.ChangeRolesNode
import io.element.android.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.ChangeRoomPermissionsSection
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.room.JoinedRoom
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)
class RolesAndPermissionsFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint,
private val joinedRoom: JoinedRoom,
) : BaseFlowNode<RolesAndPermissionsFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.AdminSettings,
@@ -53,6 +60,16 @@ class RolesAndPermissionsFlowNode @AssistedInject constructor(
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 {
return when (navTarget) {
is NavTarget.AdminSettings -> {
@@ -83,18 +100,16 @@ class RolesAndPermissionsFlowNode @AssistedInject constructor(
)
}
is NavTarget.AdminList -> {
val inputs = ChangeRolesNode.Inputs(ChangeRolesNode.ListType.Admins)
createNode<ChangeRolesNode>(
buildContext = buildContext,
plugins = listOf(inputs),
)
changeRoomMemberRolesEntryPoint.builder(this, buildContext)
.room(joinedRoom)
.listType(ChangeRoomMemberRolesListType.Admins)
.build()
}
is NavTarget.ModeratorList -> {
val inputs = ChangeRolesNode.Inputs(ChangeRolesNode.ListType.Moderators)
createNode<ChangeRolesNode>(
buildContext = buildContext,
plugins = listOf(inputs),
)
changeRoomMemberRolesEntryPoint.builder(this, buildContext)
.room(joinedRoom)
.listType(ChangeRoomMemberRolesListType.Moderators)
.build()
}
is NavTarget.ChangeRoomPermissions -> {
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 io.element.android.features.leaveroom.api.LeaveRoomEvent
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.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
@@ -548,8 +547,8 @@ class RoomDetailsPresenterTest {
dispatchers = testCoroutineDispatchers()
)
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
awaitItem().eventSink(RoomDetailsEvent.LeaveRoom)
leaveRoomEventRecorder.assertSingle(LeaveRoomEvent.ShowConfirmation(room.roomId))
awaitItem().eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
leaveRoomEventRecorder.assertSingle(LeaveRoomEvent.LeaveRoom(room.roomId, needsConfirmation = true))
cancelAndIgnoreRemainingEvents()
}
}

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ dependencies {
api(libs.dagger)
api(libs.appyx.core)
api(libs.androidx.lifecycle.runtime)
api(libs.molecule.runtime)
testImplementation(libs.test.junit)
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.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.activeRoomMembers
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
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.
*/
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
.map { roomInfo -> roomInfo.usersWithRole(role) }
.combine(membersStateFlow) { powerLevels, membersState ->
.combine(readyMembersFlow) { powerLevels, membersState ->
membersState.activeRoomMembers()
.filter { powerLevels.contains(it.userId) }
.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.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import kotlinx.collections.immutable.persistentListOf
fun aRoomMember(
userId: UserId = UserId("@alice:server.org"),
@@ -34,3 +35,31 @@ fun aRoomMember(
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

@@ -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",
"Fdroid",
"FileExtensionExtractor",
"Internal",
"LiveMediaTimeline",
"KeyStore",
"Matrix",

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