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:
committed by
GitHub
parent
a87bbdd91c
commit
955263bee1
@@ -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/")
|
||||
|
||||
@@ -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)
|
||||
|
||||
27
features/changeroommemberroles/api/build.gradle.kts
Normal file
27
features/changeroommemberroles/api/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
51
features/changeroommemberroles/impl/build.gradle.kts
Normal file
51
features/changeroommemberroles/impl/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 -> {
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
@@ -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>()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -73,7 +73,7 @@ class RoomListContextMenuTest {
|
||||
eventsRecorder.assertList(
|
||||
listOf(
|
||||
RoomListEvents.HideContextMenu,
|
||||
RoomListEvents.LeaveRoom(contextMenu.roomId),
|
||||
RoomListEvents.LeaveRoom(contextMenu.roomId, needsConfirmation = true),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,7 +289,8 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomL
|
||||
onMenuActionClick = onMenuActionClick,
|
||||
onDeclineInviteAndBlockUser = onDeclineInviteAndBlockUser,
|
||||
onReportRoomClick = onReportRoomClick,
|
||||
acceptDeclineInviteView = { },
|
||||
acceptDeclineInviteView = {},
|
||||
leaveRoomView = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -14,7 +14,5 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = {})
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
libraries/previewutils/build.gradle.kts
Normal file
21
libraries/previewutils/build.gradle.kts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -149,6 +149,7 @@ class KonsistClassNameTest {
|
||||
"Enterprise",
|
||||
"Fdroid",
|
||||
"FileExtensionExtractor",
|
||||
"Internal",
|
||||
"LiveMediaTimeline",
|
||||
"KeyStore",
|
||||
"Matrix",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user