From 955263bee1b25ded78b3f29e0d75afccd202f5e7 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Tue, 5 Aug 2025 17:24:14 +0200 Subject: [PATCH] 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 Co-authored-by: ganfra --- app/build.gradle.kts | 1 + .../android/appnav/LoggedInEventProcessor.kt | 2 + .../api/build.gradle.kts | 27 ++++ .../api/ChangeRoomMemberRolesEntryPoint.kt | 36 +++++ .../impl/build.gradle.kts | 51 ++++++ .../impl}/ChangeRolesEvent.kt | 2 +- .../impl}/ChangeRolesNode.kt | 36 +++-- .../impl}/ChangeRolesPresenter.kt | 18 ++- .../impl}/ChangeRolesState.kt | 4 +- .../impl}/ChangeRolesStateProvider.kt | 7 +- .../impl}/ChangeRolesView.kt | 37 +++-- .../impl/ChangeRoomMemberRolesRootNode.kt | 82 ++++++++++ .../DefaultChangeRoomMemberRolesEntyPoint.kt | 47 ++++++ .../impl/RoomMemberListDataSource.kt | 37 +++++ .../impl/src/main/res/values/localazy.xml | 70 ++++++++ .../impl}/ChangeRolesPresenterTest.kt | 57 ++++++- .../impl}/ChangeRolesViewTest.kt | 35 ++-- .../impl}/MembersByRoleTest.kt | 2 +- features/home/impl/build.gradle.kts | 1 + .../features/home/impl/HomeFlowNode.kt | 79 ++++++++-- .../android/features/home/impl/HomeView.kt | 7 +- .../home/impl/roomlist/RoomListContextMenu.kt | 2 +- .../home/impl/roomlist/RoomListEvents.kt | 2 +- .../home/impl/roomlist/RoomListPresenter.kt | 6 +- .../impl/roomlist/RoomListStateProvider.kt | 8 +- .../impl/roomlist/RoomListContextMenuTest.kt | 2 +- .../impl/roomlist/RoomListPresenterTest.kt | 5 +- .../home/impl/roomlist/RoomListViewTest.kt | 3 +- .../AcceptDeclineInvitePresenter.kt | 8 +- .../acceptdecline/AcceptDeclineInviteView.kt | 16 +- .../InternalAcceptDeclineInviteEvents.kt | 5 +- .../AcceptDeclineInvitePresenterTest.kt | 6 +- features/leaveroom/api/build.gradle.kts | 2 - .../features/leaveroom/api/LeaveRoomEvent.kt | 7 +- .../leaveroom/api/LeaveRoomRenderer.kt | 21 +++ .../features/leaveroom/api/LeaveRoomState.kt | 27 +--- .../leaveroom/api/LeaveRoomStateProvider.kt | 66 -------- .../features/leaveroom/api/LeaveRoomView.kt | 124 --------------- features/leaveroom/impl/build.gradle.kts | 4 +- .../leaveroom/impl/InternalLeaveRoomEvent.kt | 14 ++ .../impl/InternalLeaveRoomRenderer.kt | 29 ++++ .../leaveroom/impl/InternalLeaveRoomState.kt | 28 ++++ .../impl/InternalLeaveRoomStateProvider.kt | 51 ++++++ .../leaveroom/impl/LeaveRoomPresenter.kt | 122 +++++++------- .../features/leaveroom/impl/LeaveRoomView.kt | 149 ++++++++++++++++++ .../impl/LeaveBaseRoomPresenterTest.kt | 113 +++++-------- features/roomdetails/impl/build.gradle.kts | 1 + .../roomdetails/impl/RoomDetailsEvent.kt | 2 +- .../roomdetails/impl/RoomDetailsFlowNode.kt | 43 ++++- .../roomdetails/impl/RoomDetailsNode.kt | 55 +++++-- .../roomdetails/impl/RoomDetailsPresenter.kt | 5 +- .../impl/RoomDetailsStateProvider.kt | 8 +- .../roomdetails/impl/RoomDetailsView.kt | 7 +- .../RolesAndPermissionsFlowNode.kt | 37 +++-- .../impl/RoomDetailsPresenterTest.kt | 5 +- .../roomdetails/impl/RoomDetailsViewTest.kt | 3 +- gradle.properties | 1 + libraries/architecture/build.gradle.kts | 1 + .../libraries/architecture/appyx/NodeExt.kt | 34 ++++ .../powerlevels/MatrixRoomMembersWithRole.kt | 14 +- .../matrix/test/room/RoomMemberFixture.kt | 29 ++++ .../ui/room/PowerLevelRoomMemberComparator.kt | 28 ++++ libraries/previewutils/build.gradle.kts | 21 +++ .../previewutils/room/RoomMemberFixture.kt | 65 ++++++++ .../tests/konsist/KonsistClassNameTest.kt | 1 + ...erroles.impl_ChangeRolesView_Day_0_en.png} | 0 ...rroles.impl_ChangeRolesView_Day_10_en.png} | 0 ...rroles.impl_ChangeRolesView_Day_11_en.png} | 0 ...erroles.impl_ChangeRolesView_Day_12_en.png | 3 + ...erroles.impl_ChangeRolesView_Day_1_en.png} | 0 ...erroles.impl_ChangeRolesView_Day_2_en.png} | 0 ...erroles.impl_ChangeRolesView_Day_3_en.png} | 0 ...erroles.impl_ChangeRolesView_Day_4_en.png} | 0 ...erroles.impl_ChangeRolesView_Day_5_en.png} | 0 ...erroles.impl_ChangeRolesView_Day_6_en.png} | 0 ...erroles.impl_ChangeRolesView_Day_7_en.png} | 0 ...erroles.impl_ChangeRolesView_Day_8_en.png} | 0 ...erroles.impl_ChangeRolesView_Day_9_en.png} | 0 ...roles.impl_ChangeRolesView_Night_0_en.png} | 0 ...oles.impl_ChangeRolesView_Night_10_en.png} | 0 ...oles.impl_ChangeRolesView_Night_11_en.png} | 0 ...roles.impl_ChangeRolesView_Night_12_en.png | 3 + ...roles.impl_ChangeRolesView_Night_1_en.png} | 0 ...roles.impl_ChangeRolesView_Night_2_en.png} | 0 ...roles.impl_ChangeRolesView_Night_3_en.png} | 0 ...roles.impl_ChangeRolesView_Night_4_en.png} | 0 ...roles.impl_ChangeRolesView_Night_5_en.png} | 0 ...roles.impl_ChangeRolesView_Night_6_en.png} | 0 ...roles.impl_ChangeRolesView_Night_7_en.png} | 0 ...roles.impl_ChangeRolesView_Night_8_en.png} | 0 ...roles.impl_ChangeRolesView_Night_9_en.png} | 0 ...PendingMemberRowWithLongName_Day_0_en.png} | 0 ...ndingMemberRowWithLongName_Night_0_en.png} | 0 ...s.leaveroom.api_LeaveRoomView_Day_5_en.png | 3 - ...leaveroom.api_LeaveRoomView_Night_5_en.png | 3 - ...leaveroom.impl_LeaveRoomView_Day_0_en.png} | 0 ...leaveroom.impl_LeaveRoomView_Day_1_en.png} | 0 ...leaveroom.impl_LeaveRoomView_Day_2_en.png} | 0 ...leaveroom.impl_LeaveRoomView_Day_3_en.png} | 0 ...leaveroom.impl_LeaveRoomView_Day_4_en.png} | 0 ....leaveroom.impl_LeaveRoomView_Day_5_en.png | 3 + ...leaveroom.impl_LeaveRoomView_Day_6_en.png} | 0 ....leaveroom.impl_LeaveRoomView_Day_7_en.png | 3 + ...averoom.impl_LeaveRoomView_Night_0_en.png} | 0 ...averoom.impl_LeaveRoomView_Night_1_en.png} | 0 ...averoom.impl_LeaveRoomView_Night_2_en.png} | 0 ...averoom.impl_LeaveRoomView_Night_3_en.png} | 0 ...averoom.impl_LeaveRoomView_Night_4_en.png} | 0 ...eaveroom.impl_LeaveRoomView_Night_5_en.png | 3 + ...averoom.impl_LeaveRoomView_Night_6_en.png} | 0 ...eaveroom.impl_LeaveRoomView_Night_7_en.png | 3 + tools/localazy/config.json | 8 + 112 files changed, 1337 insertions(+), 513 deletions(-) create mode 100644 features/changeroommemberroles/api/build.gradle.kts create mode 100644 features/changeroommemberroles/api/src/main/kotlin/io/element/android/features/changeroommemberroes/api/ChangeRoomMemberRolesEntryPoint.kt create mode 100644 features/changeroommemberroles/impl/build.gradle.kts rename features/{roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles => changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl}/ChangeRolesEvent.kt (89%) rename features/{roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles => changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl}/ChangeRolesNode.kt (60%) rename features/{roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles => changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl}/ChangeRolesPresenter.kt (92%) rename features/{roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles => changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl}/ChangeRolesState.kt (91%) rename features/{roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles => changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl}/ChangeRolesStateProvider.kt (94%) rename features/{roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles => changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl}/ChangeRolesView.kt (91%) create mode 100644 features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRoomMemberRolesRootNode.kt create mode 100644 features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/DefaultChangeRoomMemberRolesEntyPoint.kt create mode 100644 features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/RoomMemberListDataSource.kt create mode 100644 features/changeroommemberroles/impl/src/main/res/values/localazy.xml rename features/{roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles => changeroommemberroles/impl/src/test/kotlin/io/element/android/features/changeroommemberroles/impl}/ChangeRolesPresenterTest.kt (91%) rename features/{roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles => changeroommemberroles/impl/src/test/kotlin/io/element/android/features/changeroommemberroles/impl}/ChangeRolesViewTest.kt (94%) rename features/{roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles => changeroommemberroles/impl/src/test/kotlin/io/element/android/features/changeroommemberroles/impl}/MembersByRoleTest.kt (98%) create mode 100644 features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomRenderer.kt delete mode 100644 features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomStateProvider.kt delete mode 100644 features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomView.kt create mode 100644 features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomEvent.kt create mode 100644 features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomRenderer.kt create mode 100644 features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomState.kt create mode 100644 features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomStateProvider.kt create mode 100644 features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomView.kt create mode 100644 libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/appyx/NodeExt.kt create mode 100644 libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/PowerLevelRoomMemberComparator.kt create mode 100644 libraries/previewutils/build.gradle.kts create mode 100644 libraries/previewutils/src/main/kotlin/io/element/android/libraries/previewutils/room/RoomMemberFixture.kt rename tests/uitests/src/test/snapshots/images/{features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_0_en.png => features.changeroommemberroles.impl_ChangeRolesView_Day_0_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_10_en.png => features.changeroommemberroles.impl_ChangeRolesView_Day_10_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_11_en.png => features.changeroommemberroles.impl_ChangeRolesView_Day_11_en.png} (100%) create mode 100644 tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Day_12_en.png rename tests/uitests/src/test/snapshots/images/{features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_1_en.png => features.changeroommemberroles.impl_ChangeRolesView_Day_1_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_2_en.png => features.changeroommemberroles.impl_ChangeRolesView_Day_2_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_3_en.png => features.changeroommemberroles.impl_ChangeRolesView_Day_3_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_4_en.png => features.changeroommemberroles.impl_ChangeRolesView_Day_4_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_5_en.png => features.changeroommemberroles.impl_ChangeRolesView_Day_5_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_6_en.png => features.changeroommemberroles.impl_ChangeRolesView_Day_6_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_7_en.png => features.changeroommemberroles.impl_ChangeRolesView_Day_7_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_8_en.png => features.changeroommemberroles.impl_ChangeRolesView_Day_8_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_9_en.png => features.changeroommemberroles.impl_ChangeRolesView_Day_9_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_0_en.png => features.changeroommemberroles.impl_ChangeRolesView_Night_0_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_10_en.png => features.changeroommemberroles.impl_ChangeRolesView_Night_10_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_11_en.png => features.changeroommemberroles.impl_ChangeRolesView_Night_11_en.png} (100%) create mode 100644 tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Night_12_en.png rename tests/uitests/src/test/snapshots/images/{features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_1_en.png => features.changeroommemberroles.impl_ChangeRolesView_Night_1_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_2_en.png => features.changeroommemberroles.impl_ChangeRolesView_Night_2_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_3_en.png => features.changeroommemberroles.impl_ChangeRolesView_Night_3_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_4_en.png => features.changeroommemberroles.impl_ChangeRolesView_Night_4_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_5_en.png => features.changeroommemberroles.impl_ChangeRolesView_Night_5_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_6_en.png => features.changeroommemberroles.impl_ChangeRolesView_Night_6_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_7_en.png => features.changeroommemberroles.impl_ChangeRolesView_Night_7_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_8_en.png => features.changeroommemberroles.impl_ChangeRolesView_Night_8_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_9_en.png => features.changeroommemberroles.impl_ChangeRolesView_Night_9_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.roomdetails.impl.rolesandpermissions.changeroles_PendingMemberRowWithLongName_Day_0_en.png => features.changeroommemberroles.impl_PendingMemberRowWithLongName_Day_0_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.roomdetails.impl.rolesandpermissions.changeroles_PendingMemberRowWithLongName_Night_0_en.png => features.changeroommemberroles.impl_PendingMemberRowWithLongName_Night_0_en.png} (100%) delete mode 100644 tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Day_5_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Night_5_en.png rename tests/uitests/src/test/snapshots/images/{features.leaveroom.api_LeaveRoomView_Day_0_en.png => features.leaveroom.impl_LeaveRoomView_Day_0_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.leaveroom.api_LeaveRoomView_Day_1_en.png => features.leaveroom.impl_LeaveRoomView_Day_1_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.leaveroom.api_LeaveRoomView_Day_2_en.png => features.leaveroom.impl_LeaveRoomView_Day_2_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.leaveroom.api_LeaveRoomView_Day_3_en.png => features.leaveroom.impl_LeaveRoomView_Day_3_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.leaveroom.api_LeaveRoomView_Day_6_en.png => features.leaveroom.impl_LeaveRoomView_Day_4_en.png} (100%) create mode 100644 tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Day_5_en.png rename tests/uitests/src/test/snapshots/images/{features.leaveroom.api_LeaveRoomView_Day_4_en.png => features.leaveroom.impl_LeaveRoomView_Day_6_en.png} (100%) create mode 100644 tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Day_7_en.png rename tests/uitests/src/test/snapshots/images/{features.leaveroom.api_LeaveRoomView_Night_0_en.png => features.leaveroom.impl_LeaveRoomView_Night_0_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.leaveroom.api_LeaveRoomView_Night_1_en.png => features.leaveroom.impl_LeaveRoomView_Night_1_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.leaveroom.api_LeaveRoomView_Night_2_en.png => features.leaveroom.impl_LeaveRoomView_Night_2_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.leaveroom.api_LeaveRoomView_Night_3_en.png => features.leaveroom.impl_LeaveRoomView_Night_3_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.leaveroom.api_LeaveRoomView_Night_6_en.png => features.leaveroom.impl_LeaveRoomView_Night_4_en.png} (100%) create mode 100644 tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Night_5_en.png rename tests/uitests/src/test/snapshots/images/{features.leaveroom.api_LeaveRoomView_Night_4_en.png => features.leaveroom.impl_LeaveRoomView_Night_6_en.png} (100%) create mode 100644 tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Night_7_en.png diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f233620cdb..ea45e51bf8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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/") diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt index 65980e3427..3f403ea8e0 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt @@ -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) diff --git a/features/changeroommemberroles/api/build.gradle.kts b/features/changeroommemberroles/api/build.gradle.kts new file mode 100644 index 0000000000..9ec13286da --- /dev/null +++ b/features/changeroommemberroles/api/build.gradle.kts @@ -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) +} diff --git a/features/changeroommemberroles/api/src/main/kotlin/io/element/android/features/changeroommemberroes/api/ChangeRoomMemberRolesEntryPoint.kt b/features/changeroommemberroles/api/src/main/kotlin/io/element/android/features/changeroommemberroes/api/ChangeRoomMemberRolesEntryPoint.kt new file mode 100644 index 0000000000..0175cdf247 --- /dev/null +++ b/features/changeroommemberroles/api/src/main/kotlin/io/element/android/features/changeroommemberroes/api/ChangeRoomMemberRolesEntryPoint.kt @@ -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 +} diff --git a/features/changeroommemberroles/impl/build.gradle.kts b/features/changeroommemberroles/impl/build.gradle.kts new file mode 100644 index 0000000000..dfe7690aed --- /dev/null +++ b/features/changeroommemberroles/impl/build.gradle.kts @@ -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) +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesEvent.kt b/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesEvent.kt similarity index 89% rename from features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesEvent.kt rename to features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesEvent.kt index 45ac7dd7bf..ab8dbc8f22 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesEvent.kt +++ b/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesEvent.kt @@ -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 diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesNode.kt b/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesNode.kt similarity index 60% rename from features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesNode.kt rename to features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesNode.kt index b4750a96ee..5b8a839658 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesNode.kt +++ b/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesNode.kt @@ -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, 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, diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesPresenter.kt b/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesPresenter.kt similarity index 92% rename from features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesPresenter.kt rename to features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesPresenter.kt index b5e5b63854..ce971464f4 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesPresenter.kt +++ b/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesPresenter.kt @@ -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 +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesState.kt b/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesState.kt similarity index 91% rename from features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesState.kt rename to features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesState.kt index 445b5aa347..027ef76e69 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesState.kt +++ b/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesState.kt @@ -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 diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesStateProvider.kt b/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesStateProvider.kt similarity index 94% rename from features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesStateProvider.kt rename to features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesStateProvider.kt index 2b62508d97..2041c0f447 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesStateProvider.kt +++ b/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesStateProvider.kt @@ -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 { aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Success(Unit)), aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Failure(Exception("boom"))), aChangeRolesStateWithOwners(), + aChangeRolesStateWithOwners().copy(role = RoomMember.Role.Owner(isCreator = false)), ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesView.kt b/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesView.kt similarity index 91% rename from features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesView.kt rename to features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesView.kt index 2fdf1dcf1b..c6b70a82f4 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesView.kt +++ b/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesView.kt @@ -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 -> { diff --git a/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRoomMemberRolesRootNode.kt b/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRoomMemberRolesRootNode.kt new file mode 100644 index 0000000000..881e93e934 --- /dev/null +++ b/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRoomMemberRolesRootNode.kt @@ -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, + roomComponentFactory: RoomComponentFactory, +) : ParentNode( + 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() + + override val daggerComponent = roomComponentFactory.create(inputs.joinedRoom) + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.Root -> { + createNode( + 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().waitForRoleChanged() + } +} diff --git a/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/DefaultChangeRoomMemberRolesEntyPoint.kt b/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/DefaultChangeRoomMemberRolesEntyPoint.kt new file mode 100644 index 0000000000..4dc26cde8e --- /dev/null +++ b/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/DefaultChangeRoomMemberRolesEntyPoint.kt @@ -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( + buildContext = buildContext, + plugins = listOf( + ChangeRoomMemberRolesRootNode.Inputs(joinedRoom = room, listType = changeRoomMemberRolesListType), + ) + ) + } + } + } +} diff --git a/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/RoomMemberListDataSource.kt b/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/RoomMemberListDataSource.kt new file mode 100644 index 0000000000..184a7058c7 --- /dev/null +++ b/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/RoomMemberListDataSource.kt @@ -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 = 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 + } +} diff --git a/features/changeroommemberroles/impl/src/main/res/values/localazy.xml b/features/changeroommemberroles/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..6e4918cde2 --- /dev/null +++ b/features/changeroommemberroles/impl/src/main/res/values/localazy.xml @@ -0,0 +1,70 @@ + + + "Admins only" + "Ban people" + "Remove messages" + "Everyone" + "Invite people and accept requests to join" + "Member moderation" + "Messages and content" + "Admins and moderators" + "Remove people and decline requests to join" + "Change room avatar" + "Room details" + "Change room name" + "Change room topic" + "Send messages" + "Edit Admins" + "You will not be able to undo this action. You are promoting the user to have the same power level as you." + "Add Admin?" + "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." + "Transfer ownership?" + "Demote" + "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." + "Demote yourself?" + "%1$s (Pending)" + "(Pending)" + "Admins automatically have moderator privileges" + "Owners automatically have admin privileges." + "Edit Moderators" + "Choose Owners" + "Admins" + "Moderators" + "Members" + "You have unsaved changes." + "Save changes?" + "There are no banned users in this room." + + "%1$d person" + "%1$d people" + + "Ban from room" + "Only remove member" + "Unban" + "They will be able to join this room again if invited." + "Unban user" + "Banned" + "Members" + "Pending" + "Admin" + "Moderator" + "Owner" + "Room members" + "Unbanning %1$s" + "Admins" + "Admins and owners" + "Change my role" + "Demote to member" + "Demote to moderator" + "Member moderation" + "Messages and content" + "Moderators" + "Owners" + "Permissions" + "Reset permissions" + "Once you reset permissions, you will lose the current settings." + "Reset permissions?" + "Roles" + "Room details" + "Roles and permissions" + diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesPresenterTest.kt b/features/changeroommemberroles/impl/src/test/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesPresenterTest.kt similarity index 91% rename from features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesPresenterTest.kt rename to features/changeroommemberroles/impl/src/test/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesPresenterTest.kt index e9c48cb209..a5b2da566c 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesPresenterTest.kt +++ b/features/changeroommemberroles/impl/src/test/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesPresenterTest.kt @@ -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): 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 { diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesViewTest.kt b/features/changeroommemberroles/impl/src/test/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesViewTest.kt similarity index 94% rename from features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesViewTest.kt rename to features/changeroommemberroles/impl/src/test/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesViewTest.kt index e5aedece1e..fb1dc38aae 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesViewTest.kt +++ b/features/changeroommemberroles/impl/src/test/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesViewTest.kt @@ -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() @@ -192,6 +178,23 @@ class ChangeRolesViewTest { eventsRecorder.assertSingle(ChangeRolesEvent.Save) } + @Test + fun `save owners confirmation dialog - continue saves the changes`() { + val eventsRecorder = EventsRecorder() + 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() diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/MembersByRoleTest.kt b/features/changeroommemberroles/impl/src/test/kotlin/io/element/android/features/changeroommemberroles/impl/MembersByRoleTest.kt similarity index 98% rename from features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/MembersByRoleTest.kt rename to features/changeroommemberroles/impl/src/test/kotlin/io/element/android/features/changeroommemberroles/impl/MembersByRoleTest.kt index 8241f4b781..a2e134f807 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/MembersByRoleTest.kt +++ b/features/changeroommemberroles/impl/src/test/kotlin/io/element/android/features/changeroommemberroles/impl/MembersByRoleTest.kt @@ -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 diff --git a/features/home/impl/build.gradle.kts b/features/home/impl/build.gradle.kts index 88184bcbcd..972c0817e2 100644 --- a/features/home/impl/build.gradle.kts +++ b/features/home/impl/build.gradle.kts @@ -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) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt index 4a7e5eb258..54c58d7387 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt @@ -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, + 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( 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) } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt index 30a5c86243..97ba7a62fd 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt @@ -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 = {} ) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenu.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenu.kt index 2a6cfee502..f729667e53 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenu.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenu.kt @@ -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)) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvents.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvents.kt index 4ba37d810b..02df2cac35 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvents.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvents.kt @@ -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 diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt index 45e4dd4238..1cab846021 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt @@ -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) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt index b28ff0cce9..55fc8948f6 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt @@ -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 = AsyncAction.Uninitialized, declineAction: AsyncAction = AsyncAction.Uninitialized, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenuTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenuTest.kt index b336178d22..66753da035 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenuTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenuTest.kt @@ -73,7 +73,7 @@ class RoomListContextMenuTest { eventsRecorder.assertList( listOf( RoomListEvents.HideContextMenu, - RoomListEvents.LeaveRoom(contextMenu.roomId), + RoomListEvents.LeaveRoom(contextMenu.roomId, needsConfirmation = true), ) ) } diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt index bc0163585f..11db1b80a5 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt @@ -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() } } diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt index 0465926abc..e29b2cf580 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt @@ -289,7 +289,8 @@ private fun AndroidComposeTestRule.setRoomL onMenuActionClick = onMenuActionClick, onDeclineInviteAndBlockUser = onDeclineInviteAndBlockUser, onReportRoomClick = onReportRoomClick, - acceptDeclineInviteView = { }, + acceptDeclineInviteView = {}, + leaveRoomView = {}, ) } } diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInvitePresenter.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInvitePresenter.kt index 6dd75761d3..163912392e 100644 --- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInvitePresenter.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInvitePresenter.kt @@ -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 } } diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInviteView.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInviteView.kt index 9c672594e0..46cc496508 100644 --- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInviteView.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInviteView.kt @@ -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) } ) } diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/InternalAcceptDeclineInviteEvents.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/InternalAcceptDeclineInviteEvents.kt index 4423430432..df05b69645 100644 --- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/InternalAcceptDeclineInviteEvents.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/InternalAcceptDeclineInviteEvents.kt @@ -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 } diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInvitePresenterTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInvitePresenterTest.kt index c8a427bea6..6a5c225acb 100644 --- a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInvitePresenterTest.kt +++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInvitePresenterTest.kt @@ -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 -> diff --git a/features/leaveroom/api/build.gradle.kts b/features/leaveroom/api/build.gradle.kts index e988d87c23..48d78b6e7e 100644 --- a/features/leaveroom/api/build.gradle.kts +++ b/features/leaveroom/api/build.gradle.kts @@ -14,7 +14,5 @@ android { dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.designsystem) - implementation(projects.libraries.uiStrings) implementation(projects.libraries.matrix.api) } diff --git a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomEvent.kt b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomEvent.kt index 11f1c5d20c..aaa7753aba 100644 --- a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomEvent.kt +++ b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomEvent.kt @@ -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 } diff --git a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomRenderer.kt b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomRenderer.kt new file mode 100644 index 0000000000..8bd6fe830b --- /dev/null +++ b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomRenderer.kt @@ -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, + ) +} diff --git a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomState.kt b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomState.kt index cf8f8da9b6..28cee237a1 100644 --- a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomState.kt +++ b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomState.kt @@ -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 } diff --git a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomStateProvider.kt b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomStateProvider.kt deleted file mode 100644 index e5bcbfcf45..0000000000 --- a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomStateProvider.kt +++ /dev/null @@ -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 { - override val values: Sequence - 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, -) diff --git a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomView.kt b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomView.kt deleted file mode 100644 index 28321d1b99..0000000000 --- a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomView.kt +++ /dev/null @@ -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) - } -} diff --git a/features/leaveroom/impl/build.gradle.kts b/features/leaveroom/impl/build.gradle.kts index d346897c75..1a31023f04 100644 --- a/features/leaveroom/impl/build.gradle.kts +++ b/features/leaveroom/impl/build.gradle.kts @@ -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) diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomEvent.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomEvent.kt new file mode 100644 index 0000000000..80aaddc442 --- /dev/null +++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomEvent.kt @@ -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 +} diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomRenderer.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomRenderer.kt new file mode 100644 index 0000000000..254a3af1ad --- /dev/null +++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomRenderer.kt @@ -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}") + } + } +} diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomState.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomState.kt new file mode 100644 index 0000000000..91d5ac84c4 --- /dev/null +++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomState.kt @@ -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, + 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 +} diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomStateProvider.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomStateProvider.kt new file mode 100644 index 0000000000..9437752c89 --- /dev/null +++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomStateProvider.kt @@ -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 { + override val values: Sequence + 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 = AsyncAction.Uninitialized, + eventSink: (LeaveRoomEvent) -> Unit = {}, +) = InternalLeaveRoomState( + leaveAction = leaveAction, + eventSink = eventSink, +) diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt index 915d65b71f..2f1bab248f 100644 --- a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt +++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt @@ -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.Hidden) } - val progress = remember { mutableStateOf(LeaveRoomState.Progress.Hidden) } - val error = remember { mutableStateOf(LeaveRoomState.Error.Hidden) } - - return LeaveRoomState( - confirmation = confirmation.value, - progress = progress.value, - error = error.value, + val leaveAction = remember { mutableStateOf>(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, -) { - 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>, + ) = 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>, + ) = 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, - progress: MutableState, - error: MutableState, -) { - 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 -} diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomView.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomView.kt new file mode 100644 index 0000000000..87e4537b1a --- /dev/null +++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomView.kt @@ -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 = {}) + } +} diff --git a/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveBaseRoomPresenterTest.kt b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveBaseRoomPresenterTest.kt index 108c2ddb53..498edc801a 100644 --- a/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveBaseRoomPresenterTest.kt +++ b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveBaseRoomPresenterTest.kt @@ -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 { + return moleculeFlow(RecompositionMode.Immediate) { + present() + }.filterIsInstance(InternalLeaveRoomState::class) + } } private fun TestScope.createLeaveRoomPresenter( diff --git a/features/roomdetails/impl/build.gradle.kts b/features/roomdetails/impl/build.gradle.kts index 4e87946ba4..ad45eda8fc 100644 --- a/features/roomdetails/impl/build.gradle.kts +++ b/features/roomdetails/impl/build.gradle.kts @@ -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) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt index 93c93c345a..09c4119ac1 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt @@ -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 diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt index 986d86ea41..122311040e 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt @@ -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, 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( backstack = BackStack( initialElement = plugins.filterIsInstance().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(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() + } } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt index 1c76f561e2..d9df8cbc1c 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt @@ -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() + private val callback = plugins().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 + ) + } ) } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt index d35d1a313b..3356c899d0 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt @@ -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) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt index 097cb2e219..9631be01c2 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt @@ -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, diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt index 4ffe29a8bf..fe14fd983c 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt @@ -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 = {}, ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsFlowNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsFlowNode.kt index 256c9c80da..dbe1eec70a 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsFlowNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsFlowNode.kt @@ -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, + private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint, + private val joinedRoom: JoinedRoom, ) : BaseFlowNode( 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( - 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( - buildContext = buildContext, - plugins = listOf(inputs), - ) + changeRoomMemberRolesEntryPoint.builder(this, buildContext) + .room(joinedRoom) + .listType(ChangeRoomMemberRolesListType.Moderators) + .build() } is NavTarget.ChangeRoomPermissions -> { val inputs = ChangeRoomPermissionsNode.Inputs(navTarget.section) diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt index 6c8f29d873..7dd954011e 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt @@ -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() } } diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt index 2f4eebcad1..3861a20422 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt @@ -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 AndroidComposeTestRule.setRoomD onSecurityAndPrivacyClick = onSecurityAndPrivacyClick, onProfileClick = onProfileClick, onReportRoomClick = onReportRoomClick, + leaveRoomView = {}, ) } } diff --git a/gradle.properties b/gradle.properties index 513fcac955..7f6fe32a88 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/libraries/architecture/build.gradle.kts b/libraries/architecture/build.gradle.kts index 8db06089f3..c50cf39122 100644 --- a/libraries/architecture/build.gradle.kts +++ b/libraries/architecture/build.gradle.kts @@ -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) diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/appyx/NodeExt.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/appyx/NodeExt.kt new file mode 100644 index 0000000000..3874ec2080 --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/appyx/NodeExt.kt @@ -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 Node.launchMolecule(body: @Composable () -> State): StateFlow { + 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 + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomMembersWithRole.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomMembersWithRole.kt index 52a2dd691d..30719626a8 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomMembersWithRole.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomMembersWithRole.kt @@ -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> { + // 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() diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomMemberFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomMemberFixture.kt index 4c620dfd5a..e2644cdf4d 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomMemberFixture.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomMemberFixture.kt @@ -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 +) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/PowerLevelRoomMemberComparator.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/PowerLevelRoomMemberComparator.kt new file mode 100644 index 0000000000..cd0f8ad591 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/PowerLevelRoomMemberComparator.kt @@ -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 { + // 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()) + } + } + } +} diff --git a/libraries/previewutils/build.gradle.kts b/libraries/previewutils/build.gradle.kts new file mode 100644 index 0000000000..92218e9286 --- /dev/null +++ b/libraries/previewutils/build.gradle.kts @@ -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) + } +} diff --git a/libraries/previewutils/src/main/kotlin/io/element/android/libraries/previewutils/room/RoomMemberFixture.kt b/libraries/previewutils/src/main/kotlin/io/element/android/libraries/previewutils/room/RoomMemberFixture.kt new file mode 100644 index 0000000000..0c79bb7eeb --- /dev/null +++ b/libraries/previewutils/src/main/kotlin/io/element/android/libraries/previewutils/room/RoomMemberFixture.kt @@ -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 +) diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt index 1bcf7bbfca..d24a489c6a 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt @@ -149,6 +149,7 @@ class KonsistClassNameTest { "Enterprise", "Fdroid", "FileExtensionExtractor", + "Internal", "LiveMediaTimeline", "KeyStore", "Matrix", diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Day_0_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_0_en.png rename to tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Day_0_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_10_en.png b/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Day_10_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_10_en.png rename to tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Day_10_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_11_en.png b/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Day_11_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_11_en.png rename to tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Day_11_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Day_12_en.png b/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Day_12_en.png new file mode 100644 index 0000000000..5f8a5326a6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Day_12_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8cac0bd6a8e8684247c0f419bc8dbb2f709af011400125bacee268722b2a8c54 +size 54908 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Day_1_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_1_en.png rename to tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Day_1_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Day_2_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_2_en.png rename to tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Day_2_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Day_3_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_3_en.png rename to tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Day_3_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Day_4_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_4_en.png rename to tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Day_4_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Day_5_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_5_en.png rename to tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Day_5_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Day_6_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_6_en.png rename to tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Day_6_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Day_7_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_7_en.png rename to tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Day_7_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Day_8_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_8_en.png rename to tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Day_8_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_9_en.png b/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Day_9_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Day_9_en.png rename to tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Day_9_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Night_0_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_0_en.png rename to tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Night_0_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_10_en.png b/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Night_10_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_10_en.png rename to tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Night_10_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_11_en.png b/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Night_11_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_11_en.png rename to tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Night_11_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Night_12_en.png b/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Night_12_en.png new file mode 100644 index 0000000000..62c653b221 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Night_12_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:da868460a570ea296fe409780c876f898897e73f8c71523f9cdc85225769a47f +size 55578 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Night_1_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_1_en.png rename to tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Night_1_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Night_2_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_2_en.png rename to tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Night_2_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Night_3_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_3_en.png rename to tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Night_3_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Night_4_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_4_en.png rename to tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Night_4_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Night_5_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_5_en.png rename to tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Night_5_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Night_6_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_6_en.png rename to tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Night_6_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Night_7_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_7_en.png rename to tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Night_7_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Night_8_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_8_en.png rename to tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Night_8_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_9_en.png b/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Night_9_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_ChangeRolesView_Night_9_en.png rename to tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_ChangeRolesView_Night_9_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_PendingMemberRowWithLongName_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_PendingMemberRowWithLongName_Day_0_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_PendingMemberRowWithLongName_Day_0_en.png rename to tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_PendingMemberRowWithLongName_Day_0_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_PendingMemberRowWithLongName_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_PendingMemberRowWithLongName_Night_0_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.roomdetails.impl.rolesandpermissions.changeroles_PendingMemberRowWithLongName_Night_0_en.png rename to tests/uitests/src/test/snapshots/images/features.changeroommemberroles.impl_PendingMemberRowWithLongName_Night_0_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Day_5_en.png deleted file mode 100644 index efc259bf67..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Day_5_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:63ba243c3912f9d42f6efa4e98c435e687c556a9c1d272e3c6e4e0b3e3a62f42 -size 11858 diff --git a/tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Night_5_en.png deleted file mode 100644 index 8029054b00..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Night_5_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2238156a42cf5ac43ab5528b4ac5b6c1f40a3cc0deb314670dfc37d9a3d22830 -size 10365 diff --git a/tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Day_0_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Day_0_en.png rename to tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Day_0_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Day_1_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Day_1_en.png rename to tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Day_1_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Day_2_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Day_2_en.png rename to tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Day_2_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Day_3_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Day_3_en.png rename to tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Day_3_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Day_4_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Day_6_en.png rename to tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Day_4_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Day_5_en.png new file mode 100644 index 0000000000..4f27ff629b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Day_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c8dddc73db9c61fa9f6f2f576277464fa8becae6d7f38812a3c9e36f7d7591af +size 31814 diff --git a/tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Day_6_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Day_4_en.png rename to tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Day_6_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Day_7_en.png new file mode 100644 index 0000000000..6f39152c18 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Day_7_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:db20190581472583a2f493275d0f7dc46ed4a2128cef475b98730e3695b918c7 +size 19149 diff --git a/tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Night_0_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Night_0_en.png rename to tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Night_0_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Night_1_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Night_1_en.png rename to tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Night_1_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Night_2_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Night_2_en.png rename to tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Night_2_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Night_3_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Night_3_en.png rename to tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Night_3_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Night_4_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Night_6_en.png rename to tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Night_4_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Night_5_en.png new file mode 100644 index 0000000000..a73bbec83d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Night_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fd0b4c7f832a915faf450bfc37e8d597a3ef4768298066ef722b87656fcbd1c4 +size 29474 diff --git a/tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Night_6_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.leaveroom.api_LeaveRoomView_Night_4_en.png rename to tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Night_6_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Night_7_en.png new file mode 100644 index 0000000000..67ef646472 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.leaveroom.impl_LeaveRoomView_Night_7_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d4557838eceb90ec501062367413359550ccc927cd4e0c50c9a01c2d9eb0938f +size 17146 diff --git a/tools/localazy/config.json b/tools/localazy/config.json index 9044372ff0..010a722e30 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -335,6 +335,14 @@ "includeRegex" : [ "screen\\.bottom_sheet\\.manage_room_member\\..*" ] + }, + { + "name" : ":features:changeroommemberroles:impl", + "includeRegex" : [ + "screen_room_change_.*", + "screen_room_roles_.*", + "screen_room_member_list.*" + ] } ] }