First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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-compose-library")
}
android {
namespace = "io.element.android.features.leaveroom.impl"
}
setupDependencyInjection()
dependencies {
api(projects.features.leaveroom.api)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.push.api)
testCommonDependencies(libs)
testImplementation(libs.coroutines.core)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test)
}
@@ -0,0 +1,15 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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 io.element.android.features.leaveroom.api.LeaveRoomEvent
sealed interface InternalLeaveRoomEvent : LeaveRoomEvent {
data object ResetState : InternalLeaveRoomEvent
}
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2025 Element Creations 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.leaveroom.impl
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import dev.zacsweers.metro.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
@ContributesBinding(SessionScope::class)
class InternalLeaveRoomRenderer : LeaveRoomRenderer {
@Composable
override fun Render(state: LeaveRoomState, onSelectNewOwners: (RoomId) -> Unit, modifier: Modifier) {
if (state is InternalLeaveRoomState) {
LeaveRoomView(state, onSelectNewOwners)
} else {
error("Unsupported state type ${state.javaClass}")
}
}
}
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2025 Element Creations 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.leaveroom.impl
import androidx.compose.runtime.Immutable
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
data class InternalLeaveRoomState(
val leaveAction: AsyncAction<Unit>,
override val eventSink: (LeaveRoomEvent) -> Unit
) : LeaveRoomState
@Immutable
sealed interface Confirmation : AsyncAction.Confirming {
data class Dm(val roomId: RoomId) : Confirmation
data class Generic(val roomId: RoomId) : Confirmation
data class PrivateRoom(val roomId: RoomId) : Confirmation
data class LastUserInRoom(val roomId: RoomId) : Confirmation
data class LastOwnerInRoom(val roomId: RoomId) : Confirmation
}
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2025 Element Creations 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.leaveroom.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
class InternalLeaveRoomStateProvider : PreviewParameterProvider<InternalLeaveRoomState> {
override val values: Sequence<InternalLeaveRoomState>
get() = sequenceOf(
aLeaveRoomState(),
aLeaveRoomState(
leaveAction = Confirmation.Generic(roomId = A_ROOM_ID),
),
aLeaveRoomState(
leaveAction = Confirmation.PrivateRoom(roomId = A_ROOM_ID),
),
aLeaveRoomState(
leaveAction = Confirmation.LastUserInRoom(roomId = A_ROOM_ID),
),
aLeaveRoomState(
leaveAction = Confirmation.Dm(roomId = A_ROOM_ID),
),
aLeaveRoomState(
leaveAction = Confirmation.LastOwnerInRoom(roomId = A_ROOM_ID),
),
aLeaveRoomState(
leaveAction = AsyncAction.Loading,
),
aLeaveRoomState(
leaveAction = AsyncAction.Failure(RuntimeException("Something went wrong")),
),
)
}
private val A_ROOM_ID = RoomId("!aRoomId:aDomain")
fun aLeaveRoomState(
leaveAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (LeaveRoomEvent) -> Unit = {},
) = InternalLeaveRoomState(
leaveAction = leaveAction,
eventSink = eventSink,
)
@@ -0,0 +1,106 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Inject
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.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 io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import timber.log.Timber
@Inject
class LeaveRoomPresenter(
private val client: MatrixClient,
private val dispatchers: CoroutineDispatchers,
private val notificationConversationService: NotificationConversationService,
) : Presenter<LeaveRoomState> {
@Composable
override fun present(): LeaveRoomState {
val scope = rememberCoroutineScope()
val leaveAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
return InternalLeaveRoomState(
leaveAction = leaveAction.value,
) { event ->
when (event) {
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 fun CoroutineScope.showLeaveRoomAlert(
roomId: RoomId,
leaveAction: MutableState<AsyncAction<Unit>>,
) = launch(dispatchers.io) {
client.getRoom(roomId)?.use { room ->
val roomInfo = room.roomInfoFlow.first()
leaveAction.value = when {
roomInfo.isDm -> Confirmation.Dm(roomId)
room.isLastOwner() && roomInfo.joinedMembersCount > 1L -> Confirmation.LastOwnerInRoom(roomId)
// If unknown, assume the room is private
roomInfo.isPublic == null || roomInfo.isPublic == false -> Confirmation.PrivateRoom(roomId)
roomInfo.joinedMembersCount == 1L -> Confirmation.LastUserInRoom(roomId)
else -> Confirmation.Generic(roomId)
}
}
}
private fun CoroutineScope.leaveRoom(
roomId: RoomId,
leaveAction: MutableState<AsyncAction<Unit>>,
) = launch(dispatchers.io) {
leaveAction.runCatchingUpdatingState {
client.getRoom(roomId)!!.use { room ->
room
.leave()
.onSuccess { notificationConversationService.onLeftRoom(client.sessionId, roomId) }
.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
}
}
}
@@ -0,0 +1,150 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.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 = {})
}
}
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.di
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.Binds
import dev.zacsweers.metro.ContributesTo
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.impl.LeaveRoomPresenter
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.SessionScope
@ContributesTo(SessionScope::class)
@BindingContainer
interface LeaveRoomModule {
@Binds
fun bindLeaveRoomPresenter(presenter: LeaveRoomPresenter): Presenter<LeaveRoomState>
}
@@ -0,0 +1,215 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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 app.cash.molecule.RecompositionMode
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.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
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.push.test.notifications.conversations.FakeNotificationConversationService
import io.element.android.tests.testutils.WarmUpRule
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
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class LeaveBaseRoomPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state hides all dialogs`() = runTest {
createLeaveRoomPresenter()
.stateFlow()
.test {
val initialState = awaitItem()
assertThat(initialState.leaveAction).isEqualTo(AsyncAction.Uninitialized)
}
}
@Test
fun `present - show generic confirmation`() = runTest {
val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
result = FakeBaseRoom().apply {
givenRoomInfo(aRoomInfo(isDirect = false, isPublic = true, joinedMembersCount = 10))
}
)
}
)
presenter.stateFlow().test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true))
val confirmationState = awaitItem()
assertThat(confirmationState.leaveAction).isEqualTo(Confirmation.Generic(A_ROOM_ID))
}
}
@Test
fun `present - show private room confirmation`() = runTest {
val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
result = FakeBaseRoom().apply {
givenRoomInfo(aRoomInfo(isPublic = false))
},
)
}
)
presenter.stateFlow().test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true))
val confirmationState = awaitItem()
assertThat(confirmationState.leaveAction).isEqualTo(Confirmation.PrivateRoom(A_ROOM_ID))
}
}
@Test
fun `present - show last user in room confirmation`() = runTest {
val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
result = FakeBaseRoom().apply {
givenRoomInfo(aRoomInfo(joinedMembersCount = 1))
},
)
}
)
presenter.stateFlow().test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true))
val confirmationState = awaitItem()
assertThat(confirmationState.leaveAction).isEqualTo(Confirmation.LastUserInRoom(A_ROOM_ID))
}
}
@Test
fun `present - show DM confirmation`() = runTest {
val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
result = FakeBaseRoom().apply {
givenRoomInfo(aRoomInfo(isDirect = true, activeMembersCount = 2))
},
)
}
)
presenter.stateFlow().test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true))
val confirmationState = awaitItem()
assertThat(confirmationState.leaveAction).isEqualTo(Confirmation.Dm(A_ROOM_ID))
}
}
@Test
fun `present - leaving a room leaves the room`() = runTest {
val leaveRoomLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
result = FakeBaseRoom(
leaveRoomLambda = leaveRoomLambda
),
)
},
)
presenter.stateFlow().test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = false))
advanceUntilIdle()
cancelAndIgnoreRemainingEvents()
assert(leaveRoomLambda)
.isCalledOnce()
.withNoParameter()
}
}
@Test
fun `present - show error if leave room fails`() = runTest {
val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
result = FakeBaseRoom(
leaveRoomLambda = { Result.failure(RuntimeException("Blimey!")) }
),
)
}
)
presenter.stateFlow().test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = false))
val progressState = awaitItem()
assertThat(progressState.leaveAction).isEqualTo(AsyncAction.Loading)
val errorState = awaitItem()
assertThat(errorState.leaveAction).isInstanceOf(AsyncAction.Failure::class.java)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - reset state after error`() = runTest {
val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
result = FakeBaseRoom(
leaveRoomLambda = { Result.failure(RuntimeException("Blimey!")) }
),
)
}
)
presenter.stateFlow().test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = false))
skipItems(1) // Skip show progress state
val errorState = awaitItem()
assertThat(errorState.leaveAction).isInstanceOf(AsyncAction.Failure::class.java)
errorState.eventSink(InternalLeaveRoomEvent.ResetState)
val hiddenErrorState = awaitItem()
assertThat(hiddenErrorState.leaveAction).isEqualTo(AsyncAction.Uninitialized)
}
}
private fun LeaveRoomPresenter.stateFlow(): Flow<InternalLeaveRoomState> {
return moleculeFlow(RecompositionMode.Immediate) {
present()
}.filterIsInstance(InternalLeaveRoomState::class)
}
}
private fun TestScope.createLeaveRoomPresenter(
client: MatrixClient = FakeMatrixClient(),
): LeaveRoomPresenter = LeaveRoomPresenter(
client = client,
dispatchers = testCoroutineDispatchers(false),
notificationConversationService = FakeNotificationConversationService(),
)