diff --git a/features/knockrequests/impl/build.gradle.kts b/features/knockrequests/impl/build.gradle.kts index 83f7132320..2664528d74 100644 --- a/features/knockrequests/impl/build.gradle.kts +++ b/features/knockrequests/impl/build.gradle.kts @@ -14,6 +14,11 @@ plugins { android { namespace = "io.element.android.features.knockrequests.impl" + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } setupAnvil() @@ -26,11 +31,17 @@ dependencies { implementation(projects.libraries.matrixui) implementation(projects.libraries.uiStrings) implementation(projects.libraries.designsystem) + implementation(projects.libraries.featureflag.api) 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) + testImplementation(projects.libraries.featureflag.test) + testReleaseImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/KnockRequest.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/KnockRequest.kt deleted file mode 100644 index 1014d13e58..0000000000 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/KnockRequest.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2024 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only - * Please see LICENSE in the repository root for full details. - */ - -package io.element.android.features.knockrequests.impl - -import io.element.android.libraries.designsystem.components.avatar.AvatarData -import io.element.android.libraries.designsystem.components.avatar.AvatarSize -import io.element.android.libraries.matrix.api.core.UserId - -data class KnockRequest( - val userId: UserId, - val displayName: String?, - val avatarUrl: String?, - val reason: String?, - val formattedDate: String?, -) - -fun KnockRequest.getAvatarData(size: AvatarSize) = AvatarData( - id = userId.value, - name = displayName, - url = avatarUrl, - size = size, -) - -fun KnockRequest.getBestName(): String { - return displayName?.takeIf { it.isNotEmpty() } ?: userId.value -} - -fun aKnockRequest( - userId: UserId = UserId("@jacob_ross:example.com"), - displayName: String? = "Jacob Ross", - avatarUrl: String? = null, - reason: String? = "Hi, I would like to get access to this room please.", - formattedDate: String = "20 Nov 2024", -) = KnockRequest( - userId = userId, - displayName = displayName, - avatarUrl = avatarUrl, - reason = reason, - formattedDate = formattedDate, -) diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenter.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenter.kt index a61236bbb0..f155cdb4a3 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenter.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenter.kt @@ -8,35 +8,85 @@ package io.element.android.features.knockrequests.impl.banner import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import io.element.android.libraries.architecture.AsyncAction +import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable +import io.element.android.features.knockrequests.impl.data.KnockRequestsService import io.element.android.libraries.architecture.Presenter -import kotlinx.collections.immutable.persistentListOf +import io.element.android.libraries.core.coroutine.mapState +import io.element.android.libraries.core.extensions.firstIfSingle +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import javax.inject.Inject -class KnockRequestsBannerPresenter @Inject constructor() : Presenter { +private const val ACCEPT_ERROR_DISPLAY_DURATION = 1500L + +class KnockRequestsBannerPresenter @Inject constructor( + private val knockRequestsService: KnockRequestsService, + private val appCoroutineScope: CoroutineScope, +) : Presenter { @Composable override fun present(): KnockRequestsBannerState { - var shouldShowBanner by remember { mutableStateOf(false) } + val knockRequests by remember { + knockRequestsService.knockRequestsFlow.mapState { knockRequests -> + knockRequests.dataOrNull().orEmpty() + .filter { !it.isSeen } + .toImmutableList() + } + }.collectAsState() + + val permissions by knockRequestsService.permissionsFlow.collectAsState() + val showAcceptError = remember { mutableStateOf(false) } + + val shouldShowBanner by remember { + derivedStateOf { + permissions.canHandle && knockRequests.isNotEmpty() + } + } fun handleEvents(event: KnockRequestsBannerEvents) { when (event) { - is KnockRequestsBannerEvents.AcceptSingleRequest -> Unit + is KnockRequestsBannerEvents.AcceptSingleRequest -> { + appCoroutineScope.acceptSingleKnockRequest( + knockRequests = knockRequests, + displayAcceptError = showAcceptError, + ) + } is KnockRequestsBannerEvents.Dismiss -> { - shouldShowBanner = false + appCoroutineScope.launch { + knockRequestsService.markAllKnockRequestsAsSeen() + } } } } return KnockRequestsBannerState( - knockRequests = persistentListOf(), - acceptAction = AsyncAction.Uninitialized, - canAccept = false, + knockRequests = knockRequests, + displayAcceptError = showAcceptError.value, + canAccept = permissions.canAccept, isVisible = shouldShowBanner, eventSink = ::handleEvents, ) } + + private fun CoroutineScope.acceptSingleKnockRequest( + knockRequests: List, + displayAcceptError: MutableState, + ) = launch { + val knockRequest = knockRequests.firstIfSingle() + if (knockRequest != null) { + knockRequestsService.acceptKnockRequest(knockRequest, optimistic = true) + .onFailure { + displayAcceptError.value = true + delay(ACCEPT_ERROR_DISPLAY_DURATION) + displayAcceptError.value = false + } + } + } } diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerState.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerState.kt index e65cd2fb26..80d662bc5b 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerState.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerState.kt @@ -10,17 +10,15 @@ package io.element.android.features.knockrequests.impl.banner import androidx.compose.runtime.Composable import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import io.element.android.features.knockrequests.impl.KnockRequest import io.element.android.features.knockrequests.impl.R -import io.element.android.features.knockrequests.impl.getBestName -import io.element.android.libraries.architecture.AsyncAction +import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable import io.element.android.libraries.core.extensions.firstIfSingle import kotlinx.collections.immutable.ImmutableList data class KnockRequestsBannerState( val isVisible: Boolean, - val knockRequests: ImmutableList, - val acceptAction: AsyncAction, + val knockRequests: ImmutableList, + val displayAcceptError: Boolean, val canAccept: Boolean, val eventSink: (KnockRequestsBannerEvents) -> Unit, ) { diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerStateProvider.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerStateProvider.kt index c0f4cc0961..0324239fd9 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerStateProvider.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerStateProvider.kt @@ -8,9 +8,8 @@ package io.element.android.features.knockrequests.impl.banner import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.features.knockrequests.impl.KnockRequest -import io.element.android.features.knockrequests.impl.aKnockRequest -import io.element.android.libraries.architecture.AsyncAction +import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable +import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable import kotlinx.collections.immutable.toImmutableList class KnockRequestsBannerStateProvider : PreviewParameterProvider { @@ -19,7 +18,7 @@ class KnockRequestsBannerStateProvider : PreviewParameterProvider = listOf(aKnockRequest()), - acceptAction: AsyncAction = AsyncAction.Uninitialized, + knockRequests: List = listOf(aKnockRequestPresentable()), + displayAcceptError: Boolean = false, canAccept: Boolean = true, isVisible: Boolean = true, eventSink: (KnockRequestsBannerEvents) -> Unit = {} ) = KnockRequestsBannerState( knockRequests = knockRequests.toImmutableList(), - acceptAction = acceptAction, + displayAcceptError = displayAcceptError, canAccept = canAccept, isVisible = isVisible, eventSink = eventSink, diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerView.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerView.kt index 9cf7144f5e..d029b80906 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerView.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerView.kt @@ -20,9 +20,11 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset @@ -37,9 +39,11 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.features.knockrequests.impl.KnockRequest import io.element.android.features.knockrequests.impl.R -import io.element.android.features.knockrequests.impl.getAvatarData +import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable +import io.element.android.libraries.designsystem.components.async.AsyncIndicator +import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost +import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.preview.ElementPreview @@ -61,22 +65,42 @@ fun KnockRequestsBannerView( onViewRequestsClick: () -> Unit, modifier: Modifier = Modifier, ) { - AnimatedVisibility( - visible = state.isVisible, - enter = expandVertically(), - exit = shrinkVertically(), - modifier = modifier, - ) { - Surface( - shape = MaterialTheme.shapes.small, - color = ElementTheme.colors.bgCanvasDefaultLevel1, - shadowElevation = 24.dp, - modifier = Modifier.padding(16.dp), + Box(modifier = modifier) { + AnimatedVisibility( + visible = state.isVisible, + enter = expandVertically(), + exit = shrinkVertically(), ) { - KnockRequestsBannerContent( - state = state, - onViewRequestsClick = onViewRequestsClick, - ) + Surface( + shape = MaterialTheme.shapes.small, + color = ElementTheme.colors.bgCanvasDefaultLevel1, + shadowElevation = 24.dp, + modifier = Modifier.padding(16.dp), + ) { + KnockRequestsBannerContent( + state = state, + onViewRequestsClick = onViewRequestsClick, + ) + } + } + KnockRequestsAcceptErrorView(displayError = state.displayAcceptError) + } +} + +@Composable +private fun KnockRequestsAcceptErrorView( + displayError: Boolean, + modifier: Modifier = Modifier, +) { + val asyncIndicatorState = rememberAsyncIndicatorState() + AsyncIndicatorHost(modifier = modifier.statusBarsPadding(), state = asyncIndicatorState) + LaunchedEffect(displayError) { + if (displayError) { + asyncIndicatorState.enqueue { + AsyncIndicator.Custom(text = stringResource(CommonStrings.error_unknown)) + } + } else { + asyncIndicatorState.clear() } } } @@ -96,9 +120,9 @@ private fun KnockRequestsBannerContent( } Column( - modifier - .fillMaxWidth() - .padding(all = 16.dp) + modifier + .fillMaxWidth() + .padding(all = 16.dp) ) { Row { KnockRequestAvatarView( @@ -122,13 +146,15 @@ private fun KnockRequestsBannerContent( ) } } + Spacer(modifier = Modifier.width(4.dp)) Icon( modifier = Modifier.clickable(onClick = ::onDismissClick), imageVector = CompoundIcons.Close(), contentDescription = stringResource(CommonStrings.action_close) ) } - if (state.reason != null) { + val reason = state.reason + if (!reason.isNullOrEmpty()) { Spacer(modifier = Modifier.height(16.dp)) Text( text = state.reason, @@ -169,7 +195,7 @@ private fun KnockRequestsBannerContent( @Composable private fun KnockRequestAvatarView( - knockRequests: ImmutableList, + knockRequests: ImmutableList, modifier: Modifier = Modifier, ) { Box(modifier) { @@ -183,7 +209,7 @@ private fun KnockRequestAvatarView( @Composable private fun KnockRequestAvatarListView( - knockRequests: ImmutableList, + knockRequests: ImmutableList, modifier: Modifier = Modifier, ) { val avatarSize = AvatarSize.KnockRequestBanner.dp @@ -198,27 +224,27 @@ private fun KnockRequestAvatarListView( smallReversedList.forEachIndexed { index, knockRequest -> Avatar( modifier = Modifier - .padding(start = avatarSize / 2 * (lastItemIndex - index)) - .graphicsLayer { - compositingStrategy = CompositingStrategy.Offscreen - } - .drawWithContent { - // Draw content and clear the pixels for the avatar on the left. - drawContent() - if (index < lastItemIndex) { - drawCircle( - color = Color.Black, - center = Offset( - x = 0f, - y = size.height / 2, - ), - radius = avatarSize.toPx() / 2, - blendMode = BlendMode.Clear, - ) + .padding(start = avatarSize / 2 * (lastItemIndex - index)) + .graphicsLayer { + compositingStrategy = CompositingStrategy.Offscreen } - } - .size(size = avatarSize) - .padding(2.dp), + .drawWithContent { + // Draw content and clear the pixels for the avatar on the left. + drawContent() + if (index < lastItemIndex) { + drawCircle( + color = Color.Black, + center = Offset( + x = 0f, + y = size.height / 2, + ), + radius = avatarSize.toPx() / 2, + blendMode = BlendMode.Clear, + ) + } + } + .size(size = avatarSize) + .padding(2.dp), avatarData = knockRequest.getAvatarData(AvatarSize.KnockRequestBanner), ) } diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestFixture.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestFixture.kt new file mode 100644 index 0000000000..cfecb8355e --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestFixture.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.data + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId + +fun aKnockRequestPresentable( + eventId: EventId = EventId("\$eventId"), + userId: UserId = UserId("@jacob_ross:example.com"), + displayName: String? = "Jacob Ross", + avatarUrl: String? = null, + reason: String? = "Hi, I would like to get access to this room please.", + formattedDate: String? = "20 Nov 2024", +) = object : KnockRequestPresentable { + override val eventId: EventId = eventId + override val userId: UserId = userId + override val displayName: String? = displayName + override val avatarUrl: String? = avatarUrl + override val reason: String? = reason + override val formattedDate: String? = formattedDate +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestPermissions.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestPermissions.kt new file mode 100644 index 0000000000..658717d48b --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestPermissions.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.data + +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.powerlevels.canBan +import io.element.android.libraries.matrix.api.room.powerlevels.canInvite +import io.element.android.libraries.matrix.api.room.powerlevels.canKick +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +data class KnockRequestPermissions( + val canAccept: Boolean, + val canDecline: Boolean, + val canBan: Boolean, +) { + val canHandle = canAccept || canDecline || canBan +} + +fun MatrixRoom.knockRequestPermissionsFlow(): Flow { + return syncUpdateFlow.map { + val canAccept = canInvite().getOrDefault(false) + val canDecline = canKick().getOrDefault(false) + val canBan = canBan().getOrDefault(false) + KnockRequestPermissions(canAccept, canDecline, canBan) + } +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestPresentable.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestPresentable.kt new file mode 100644 index 0000000000..5d45281d8a --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestPresentable.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.data + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId + +@Immutable +interface KnockRequestPresentable { + val eventId: EventId + val userId: UserId + val displayName: String? + val avatarUrl: String? + val reason: String? + val formattedDate: String? + + fun getAvatarData(size: AvatarSize) = AvatarData( + id = userId.value, + name = displayName, + url = avatarUrl, + size = size, + ) + + fun getBestName(): String { + return displayName?.takeIf { it.isNotEmpty() } ?: userId.value + } +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestWrapper.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestWrapper.kt new file mode 100644 index 0000000000..f1df84beab --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestWrapper.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.data + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.knock.KnockRequest + +class KnockRequestWrapper( + private val inner: KnockRequest, + dateFormatter: (Long?) -> String? = { null } +) : KnockRequestPresentable { + override val eventId: EventId = inner.eventId + override val userId: UserId = inner.userId + override val displayName: String? = inner.displayName + override val avatarUrl: String? = inner.avatarUrl + override val reason: String? = inner.reason?.trim() + override val formattedDate: String? = dateFormatter(inner.timestamp) + + val isSeen: Boolean = inner.isSeen + + suspend fun accept(): Result = inner.accept() + + suspend fun decline(reason: String?): Result = inner.decline(reason) + + suspend fun declineAndBan(reason: String?): Result = inner.declineAndBan(reason) + + suspend fun markAsSeen(): Result = inner.markAsSeen() +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsException.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsException.kt new file mode 100644 index 0000000000..0880233ff6 --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsException.kt @@ -0,0 +1,13 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.data + +sealed class KnockRequestsException : Exception() { + data object AcceptAllPartiallyFailed : KnockRequestsException() + data object KnockRequestNotFound : KnockRequestsException() +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsModule.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsModule.kt new file mode 100644 index 0000000000..1c1a17767f --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsModule.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.data + +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.room.MatrixRoom + +@Module +@ContributesTo(RoomScope::class) +object KnockRequestsModule { + @Provides + @SingleIn(RoomScope::class) + fun knockRequestsService(room: MatrixRoom, featureFlagService: FeatureFlagService): KnockRequestsService { + return KnockRequestsService( + knockRequestsFlow = room.knockRequestsFlow, + permissionsFlow = room.knockRequestPermissionsFlow(), + isKnockFeatureEnabledFlow = featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock), + coroutineScope = room.roomCoroutineScope + ) + } +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsService.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsService.kt new file mode 100644 index 0000000000..fb08821387 --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsService.kt @@ -0,0 +1,145 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.data + +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.room.knock.KnockRequest +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.supervisorScope + +class KnockRequestsService( + knockRequestsFlow: Flow>, + permissionsFlow: Flow, + isKnockFeatureEnabledFlow: Flow, + coroutineScope: CoroutineScope, +) { + // Keep track of the knock requests that have been handled, so we don't have to wait for sync to remove them. + private val handledKnockRequestIds = MutableStateFlow>(emptySet()) + + val knockRequestsFlow = combine( + isKnockFeatureEnabledFlow, + knockRequestsFlow, + handledKnockRequestIds, + ) { isKnockEnabled, knockRequests, handledKnockIds -> + if (!isKnockEnabled) { + AsyncData.Success(persistentListOf()) + } else { + val presentableKnockRequests = knockRequests + .filter { it.eventId !in handledKnockIds } + .map { inner -> KnockRequestWrapper(inner) } + .toImmutableList() + AsyncData.Success(presentableKnockRequests) + } + }.stateIn(coroutineScope, SharingStarted.Lazily, AsyncData.Loading()) + + val permissionsFlow = permissionsFlow.stateIn( + scope = coroutineScope, + started = SharingStarted.Lazily, + initialValue = KnockRequestPermissions(canAccept = false, canDecline = false, canBan = false) + ) + + private fun knockRequestsList() = knockRequestsFlow.value.dataOrNull().orEmpty() + + private fun getKnockRequestById(eventId: EventId): KnockRequestWrapper? { + return knockRequestsList().find { it.eventId == eventId } + } + + /** + * Accept a knock request. + * @param knockRequest The knock request to accept. + * @param optimistic If true, the request will be marked as handled before the server responds. + */ + suspend fun acceptKnockRequest(knockRequest: KnockRequestPresentable, optimistic: Boolean = false): Result { + val wrapped = getKnockRequestById(knockRequest.eventId) ?: return knockRequestNotFoundResult() + return handleKnockRequest(wrapped, optimistic) { accept() } + } + + /** + * Decline a knock request. + * @param knockRequest The knock request to decline. + * @param optimistic If true, the request will be marked as handled before the server responds. + */ + suspend fun declineKnockRequest(knockRequest: KnockRequestPresentable, optimistic: Boolean = false): Result { + val wrapped = getKnockRequestById(knockRequest.eventId) ?: return knockRequestNotFoundResult() + return handleKnockRequest(wrapped, optimistic) { decline(null) } + } + + /** + * Decline a knock request by banning the user. + * @param knockRequest The knock request to decline. + * @param optimistic If true, the request will be marked as handled before the server responds. + */ + suspend fun declineAndBanKnockRequest(knockRequest: KnockRequestPresentable, optimistic: Boolean = false): Result { + val wrapped = getKnockRequestById(knockRequest.eventId) ?: return knockRequestNotFoundResult() + return handleKnockRequest(wrapped, optimistic) { declineAndBan(null) } + } + + /** + * Accept all currently known knock requests. + * @param optimistic If true, the requests will be marked as handled before the server responds. + */ + suspend fun acceptAllKnockRequests(optimistic: Boolean = false): Result = supervisorScope { + val results = knockRequestsList() + .map { knockRequest -> + async { + acceptKnockRequest(knockRequest, optimistic = optimistic) + } + } + .awaitAll() + if (results.all { it.isSuccess }) { + Result.success(Unit) + } else { + Result.failure(KnockRequestsException.AcceptAllPartiallyFailed) + } + } + + /** + * Mark all currently known knock requests as seen. + */ + suspend fun markAllKnockRequestsAsSeen() = supervisorScope { + knockRequestsList() + .map { knockRequest -> + async { knockRequest.markAsSeen() } + } + .awaitAll() + } + + private suspend fun handleKnockRequest( + knockRequest: KnockRequestWrapper, + optimistic: Boolean, + action: suspend (KnockRequestWrapper.() -> Result) + ): Result { + if (optimistic) { + handledKnockRequestIds.getAndUpdate { it + knockRequest.eventId } + } + return action(knockRequest) + .onFailure { + if (optimistic) { + handledKnockRequestIds.getAndUpdate { it - knockRequest.eventId } + } + } + .onSuccess { + if (!optimistic) { + handledKnockRequestIds.getAndUpdate { it + knockRequest.eventId } + } + } + } +} + +private fun knockRequestNotFoundResult() = Result.failure(KnockRequestsException.KnockRequestNotFound) diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListEvents.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListEvents.kt index 132c137ce2..23b1025ce2 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListEvents.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListEvents.kt @@ -7,12 +7,14 @@ package io.element.android.features.knockrequests.impl.list -import io.element.android.features.knockrequests.impl.KnockRequest +import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable sealed interface KnockRequestsListEvents { - data class Accept(val knockRequest: KnockRequest) : KnockRequestsListEvents - data class Decline(val knockRequest: KnockRequest) : KnockRequestsListEvents - data class DeclineAndBan(val knockRequest: KnockRequest) : KnockRequestsListEvents + data class Accept(val knockRequest: KnockRequestPresentable) : KnockRequestsListEvents + data class Decline(val knockRequest: KnockRequestPresentable) : KnockRequestsListEvents + data class DeclineAndBan(val knockRequest: KnockRequestPresentable) : KnockRequestsListEvents data object AcceptAll : KnockRequestsListEvents - data object DismissCurrentAction : KnockRequestsListEvents + data object ResetCurrentAction : KnockRequestsListEvents + data object RetryCurrentAction : KnockRequestsListEvents + data object ConfirmCurrentAction : KnockRequestsListEvents } diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenter.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenter.kt index fcafc5428b..6ea13f16a1 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenter.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenter.kt @@ -9,76 +9,112 @@ package io.element.android.features.knockrequests.impl.list import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import io.element.android.features.knockrequests.impl.data.KnockRequestsService import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.matrix.api.room.MatrixRoom -import io.element.android.libraries.matrix.ui.room.canBanAsState -import io.element.android.libraries.matrix.ui.room.canInviteAsState -import io.element.android.libraries.matrix.ui.room.canKickAsState -import kotlinx.collections.immutable.persistentListOf +import io.element.android.libraries.architecture.runUpdatingState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import javax.inject.Inject class KnockRequestsListPresenter @Inject constructor( - private val room: MatrixRoom, + private val knockRequestsService: KnockRequestsService, ) : Presenter { @Composable override fun present(): KnockRequestsListState { - val currentAction = remember { mutableStateOf(KnockRequestsCurrentAction.None) } - val syncUpdateFlow = room.syncUpdateFlow.collectAsState() - val canBan by room.canBanAsState(syncUpdateFlow.value) - val canDecline by room.canKickAsState(syncUpdateFlow.value) - val canAccept by room.canInviteAsState(syncUpdateFlow.value) + val asyncAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } + var currentAction by remember { mutableStateOf(KnockRequestsAction.None) } + + val permissions by knockRequestsService.permissionsFlow.collectAsState() + val knockRequests by knockRequestsService.knockRequestsFlow.collectAsState() + + val coroutineScope = rememberCoroutineScope() fun handleEvents(event: KnockRequestsListEvents) { when (event) { KnockRequestsListEvents.AcceptAll -> { - currentAction.value = KnockRequestsCurrentAction.AcceptAll(AsyncAction.Uninitialized) + currentAction = KnockRequestsAction.AcceptAll } is KnockRequestsListEvents.Accept -> { - currentAction.value = KnockRequestsCurrentAction.Accept(event.knockRequest, AsyncAction.Uninitialized) + currentAction = KnockRequestsAction.Accept(event.knockRequest) } is KnockRequestsListEvents.Decline -> { - currentAction.value = KnockRequestsCurrentAction.Decline(event.knockRequest, AsyncAction.Uninitialized) + currentAction = KnockRequestsAction.Decline(event.knockRequest) } is KnockRequestsListEvents.DeclineAndBan -> { - currentAction.value = KnockRequestsCurrentAction.DeclineAndBan(event.knockRequest, AsyncAction.Uninitialized) + currentAction = KnockRequestsAction.DeclineAndBan(event.knockRequest) } - KnockRequestsListEvents.DismissCurrentAction -> { - currentAction.value = KnockRequestsCurrentAction.None + KnockRequestsListEvents.ResetCurrentAction -> { + asyncAction.value = AsyncAction.Uninitialized + currentAction = KnockRequestsAction.None + } + KnockRequestsListEvents.RetryCurrentAction -> { + coroutineScope.executeAction(currentAction, asyncAction, isActionConfirmed = true) + } + KnockRequestsListEvents.ConfirmCurrentAction -> { + coroutineScope.executeAction(currentAction, asyncAction, isActionConfirmed = true) } } } - LaunchedEffect(currentAction) { - when (currentAction.value) { - is KnockRequestsCurrentAction.Accept -> { - // Accept the knock request - } - is KnockRequestsCurrentAction.Decline -> { - // Decline the knock request - } - is KnockRequestsCurrentAction.DeclineAndBan -> { - // Decline and ban the user - } - is KnockRequestsCurrentAction.AcceptAll -> { - // Accept all knock requests - } - KnockRequestsCurrentAction.None -> Unit - } + executeAction(currentAction, asyncAction, isActionConfirmed = false) } return KnockRequestsListState( - knockRequests = AsyncData.Success(persistentListOf()), - currentAction = currentAction.value, - canAccept = canAccept, - canDecline = canDecline, - canBan = canBan, + knockRequests = knockRequests, + currentAction = currentAction, + permissions = permissions, + asyncAction = asyncAction.value, eventSink = ::handleEvents ) } + + private fun CoroutineScope.executeAction( + currentAction: KnockRequestsAction, + asyncAction: MutableState>, + isActionConfirmed: Boolean, + ) = launch { + when (currentAction) { + is KnockRequestsAction.Accept -> { + runUpdatingState(asyncAction) { + knockRequestsService.acceptKnockRequest(currentAction.knockRequest) + } + } + is KnockRequestsAction.Decline -> { + if (isActionConfirmed) { + runUpdatingState(asyncAction) { + knockRequestsService.declineKnockRequest(currentAction.knockRequest) + } + } else { + asyncAction.value = AsyncAction.ConfirmingNoParams + } + } + is KnockRequestsAction.DeclineAndBan -> { + if (isActionConfirmed) { + runUpdatingState(asyncAction) { + knockRequestsService.declineAndBanKnockRequest(currentAction.knockRequest) + } + } else { + asyncAction.value = AsyncAction.ConfirmingNoParams + } + } + is KnockRequestsAction.AcceptAll -> { + if (isActionConfirmed) { + runUpdatingState(asyncAction) { + knockRequestsService.acceptAllKnockRequests() + } + } else { + asyncAction.value = AsyncAction.ConfirmingNoParams + } + } + KnockRequestsAction.None -> Unit + } + } } diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.kt index 3ba10e9302..fa33b074a5 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.kt @@ -8,27 +8,27 @@ package io.element.android.features.knockrequests.impl.list import androidx.compose.runtime.Immutable -import io.element.android.features.knockrequests.impl.KnockRequest +import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions +import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import kotlinx.collections.immutable.ImmutableList data class KnockRequestsListState( - val knockRequests: AsyncData>, - val currentAction: KnockRequestsCurrentAction, - val canAccept: Boolean, - val canDecline: Boolean, - val canBan: Boolean, + val knockRequests: AsyncData>, + val currentAction: KnockRequestsAction, + val asyncAction: AsyncAction, + val permissions: KnockRequestPermissions, val eventSink: (KnockRequestsListEvents) -> Unit, ) { - val canAcceptAll = knockRequests is AsyncData.Success && knockRequests.data.size > 1 + val canAcceptAll = permissions.canAccept && knockRequests is AsyncData.Success && knockRequests.data.size > 1 } @Immutable -sealed interface KnockRequestsCurrentAction { - data object None : KnockRequestsCurrentAction - data class Accept(val knockRequest: KnockRequest, val async: AsyncAction) : KnockRequestsCurrentAction - data class Decline(val knockRequest: KnockRequest, val async: AsyncAction) : KnockRequestsCurrentAction - data class DeclineAndBan(val knockRequest: KnockRequest, val async: AsyncAction) : KnockRequestsCurrentAction - data class AcceptAll(val async: AsyncAction) : KnockRequestsCurrentAction +sealed interface KnockRequestsAction { + data object None : KnockRequestsAction + data class Accept(val knockRequest: KnockRequestPresentable) : KnockRequestsAction + data class Decline(val knockRequest: KnockRequestPresentable) : KnockRequestsAction + data class DeclineAndBan(val knockRequest: KnockRequestPresentable) : KnockRequestsAction + data object AcceptAll : KnockRequestsAction } diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt index 476bf556e1..a8d898b08e 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt @@ -8,8 +8,9 @@ package io.element.android.features.knockrequests.impl.list import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.features.knockrequests.impl.KnockRequest -import io.element.android.features.knockrequests.impl.aKnockRequest +import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions +import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable +import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.core.UserId @@ -30,14 +31,14 @@ open class KnockRequestsListStateProvider : PreviewParameterProvider> = AsyncData.Success(persistentListOf()), - currentAction: KnockRequestsCurrentAction = KnockRequestsCurrentAction.None, - canAccept: Boolean = true, - canDecline: Boolean = true, - canBan: Boolean = true, + knockRequests: AsyncData> = AsyncData.Success(persistentListOf()), + currentAction: KnockRequestsAction = KnockRequestsAction.None, + asyncAction: AsyncAction = AsyncAction.Uninitialized, + permissions: KnockRequestPermissions = KnockRequestPermissions( + canAccept = true, + canDecline = true, + canBan = true, + ), eventSink: (KnockRequestsListEvents) -> Unit = {}, ) = KnockRequestsListState( knockRequests = knockRequests, currentAction = currentAction, - canAccept = canAccept, - canDecline = canDecline, - canBan = canBan, + asyncAction = asyncAction, + permissions = permissions, eventSink = eventSink, ) diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListView.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListView.kt index 41b5553438..09f916ae09 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListView.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListView.kt @@ -11,6 +11,7 @@ import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -46,23 +47,25 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.features.knockrequests.impl.KnockRequest import io.element.android.features.knockrequests.impl.R -import io.element.android.features.knockrequests.impl.getAvatarData -import io.element.android.features.knockrequests.impl.getBestName +import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.button.BackButton +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.designsystem.text.toDp import io.element.android.libraries.designsystem.theme.aliasScreenTitle import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.ButtonSize +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.OutlinedButton @@ -100,14 +103,18 @@ private fun KnockRequestsListContent( state: KnockRequestsListState, modifier: Modifier = Modifier, ) { - fun onAcceptClick(knockRequest: KnockRequest) { + fun onAcceptClick(knockRequest: KnockRequestPresentable) { state.eventSink(KnockRequestsListEvents.Accept(knockRequest)) } - fun onDeclineClick(knockRequest: KnockRequest) { + fun onDeclineClick(knockRequest: KnockRequestPresentable) { state.eventSink(KnockRequestsListEvents.Decline(knockRequest)) } + fun onBanClick(knockRequest: KnockRequestPresentable) { + state.eventSink(KnockRequestsListEvents.DeclineAndBan(knockRequest)) + } + var bottomPaddingInPixels by remember { mutableIntStateOf(0) } Box(modifier.fillMaxSize()) { @@ -119,21 +126,43 @@ private fun KnockRequestsListContent( } else { KnockRequestsList( knockRequests = knockRequests, - canAccept = state.canAccept, - canDecline = state.canDecline, - canBan = state.canBan, + canAccept = state.permissions.canAccept, + canDecline = state.permissions.canDecline, + canBan = state.permissions.canBan, onAcceptClick = ::onAcceptClick, onDeclineClick = ::onDeclineClick, + onBanClick = ::onBanClick, contentPadding = PaddingValues(bottom = bottomPaddingInPixels.toDp()), ) } } + is AsyncData.Loading -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = spacedBy(16.dp), + modifier = Modifier.align(Alignment.Center) + ) { + CircularProgressIndicator(color = ElementTheme.colors.iconPrimary) + Text( + text = stringResource(R.string.screen_knock_requests_list_initial_loading_title), + style = ElementTheme.typography.fontBodyLgRegular, + color = ElementTheme.colors.textPrimary, + ) + } + } else -> Unit } KnockRequestsActionsView( - actions = state.currentAction, + currentAction = state.currentAction, + asyncAction = state.asyncAction, + onConfirm = { + state.eventSink(KnockRequestsListEvents.ConfirmCurrentAction) + }, + onRetry = { + state.eventSink(KnockRequestsListEvents.RetryCurrentAction) + }, onDismiss = { - state.eventSink(KnockRequestsListEvents.DismissCurrentAction) + state.eventSink(KnockRequestsListEvents.ResetCurrentAction) }, ) if (state.canAcceptAll) { @@ -152,53 +181,104 @@ private fun KnockRequestsListContent( @Composable private fun KnockRequestsActionsView( - actions: KnockRequestsCurrentAction, + currentAction: KnockRequestsAction, + asyncAction: AsyncAction, + onConfirm: () -> Unit, onDismiss: () -> Unit, + onRetry: () -> Unit, modifier: Modifier = Modifier, ) { Box(modifier) { - when (actions) { - is KnockRequestsCurrentAction.AcceptAll -> { - AsyncActionView( - async = actions.async, - onSuccess = {}, - onErrorDismiss = onDismiss, + AsyncActionView( + async = asyncAction, + onSuccess = { onDismiss() }, + onErrorDismiss = onDismiss, + confirmationDialog = { + KnockRequestActionConfirmation( + currentAction = currentAction, + onSubmit = onConfirm, + onDismiss = onDismiss, ) - } - is KnockRequestsCurrentAction.Accept -> { - AsyncActionView( - async = actions.async, - onSuccess = {}, - onErrorDismiss = onDismiss, - ) - } - is KnockRequestsCurrentAction.Decline -> { - AsyncActionView( - async = actions.async, - onSuccess = {}, - onErrorDismiss = onDismiss, - ) - } - is KnockRequestsCurrentAction.DeclineAndBan -> { - AsyncActionView( - async = actions.async, - onSuccess = {}, - onErrorDismiss = onDismiss, - ) - } - KnockRequestsCurrentAction.None -> Unit - } + }, + progressDialog = { + KnockRequestActionProgress(target = currentAction) + }, + errorMessage = { + when (currentAction) { + is KnockRequestsAction.Accept -> stringResource(R.string.screen_knock_requests_list_accept_failed_alert_description) + is KnockRequestsAction.Decline -> stringResource(R.string.screen_knock_requests_list_decline_failed_alert_description) + is KnockRequestsAction.DeclineAndBan -> stringResource(R.string.screen_knock_requests_list_decline_failed_alert_description) + KnockRequestsAction.AcceptAll -> stringResource(R.string.screen_knock_requests_list_accept_all_failed_alert_description) + else -> "" + } + }, + onRetry = onRetry, + ) } } +@Composable +private fun KnockRequestActionConfirmation( + currentAction: KnockRequestsAction, + onSubmit: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val (title, content, submitText) = when (currentAction) { + KnockRequestsAction.AcceptAll -> Triple( + stringResource(R.string.screen_knock_requests_list_accept_all_alert_title), + stringResource(R.string.screen_knock_requests_list_accept_all_alert_description), + stringResource(R.string.screen_knock_requests_list_accept_all_alert_confirm_button_title), + ) + is KnockRequestsAction.Decline -> Triple( + stringResource(R.string.screen_knock_requests_list_decline_alert_title), + stringResource(R.string.screen_knock_requests_list_decline_alert_description, currentAction.knockRequest.getBestName()), + stringResource(R.string.screen_knock_requests_list_decline_alert_confirm_button_title), + ) + is KnockRequestsAction.DeclineAndBan -> Triple( + stringResource(R.string.screen_knock_requests_list_ban_alert_title), + stringResource(R.string.screen_knock_requests_list_ban_alert_description, currentAction.knockRequest.getBestName()), + stringResource(R.string.screen_knock_requests_list_ban_alert_confirm_button_title), + ) + else -> return + } + ConfirmationDialog( + title = title, + content = content, + submitText = submitText, + onSubmitClick = onSubmit, + onDismiss = onDismiss, + modifier = modifier, + ) +} + +@Composable +private fun KnockRequestActionProgress( + target: KnockRequestsAction, + modifier: Modifier = Modifier, +) { + val progressText = when (target) { + is KnockRequestsAction.Accept -> stringResource(R.string.screen_knock_requests_list_accept_loading_title) + is KnockRequestsAction.Decline -> stringResource(R.string.screen_knock_requests_list_decline_loading_title) + is KnockRequestsAction.DeclineAndBan -> stringResource(R.string.screen_knock_requests_list_ban_loading_title) + KnockRequestsAction.AcceptAll -> stringResource(R.string.screen_knock_requests_list_accept_all_loading_title) + else -> return + } + ProgressDialog( + text = progressText, + modifier = modifier, + ) +} + @Composable private fun KnockRequestsList( - knockRequests: ImmutableList, + knockRequests: ImmutableList, canAccept: Boolean, canDecline: Boolean, canBan: Boolean, - onAcceptClick: (KnockRequest) -> Unit, - onDeclineClick: (KnockRequest) -> Unit, + onAcceptClick: (KnockRequestPresentable) -> Unit, + onDeclineClick: (KnockRequestPresentable) -> Unit, + onBanClick: (KnockRequestPresentable) -> Unit, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(0.dp), ) { @@ -214,6 +294,7 @@ private fun KnockRequestsList( canDecline = canDecline, canAccept = canAccept, onDeclineClick = onDeclineClick, + onBanClick = onBanClick, ) if (index != knockRequests.size - 1) { HorizontalDivider() @@ -224,12 +305,13 @@ private fun KnockRequestsList( @Composable private fun KnockRequestItem( - knockRequest: KnockRequest, + knockRequest: KnockRequestPresentable, canAccept: Boolean, canDecline: Boolean, canBan: Boolean, - onAcceptClick: (KnockRequest) -> Unit, - onDeclineClick: (KnockRequest) -> Unit, + onAcceptClick: (KnockRequestPresentable) -> Unit, + onDeclineClick: (KnockRequestPresentable) -> Unit, + onBanClick: (KnockRequestPresentable) -> Unit, modifier: Modifier = Modifier, ) { Row( @@ -252,10 +334,11 @@ private fun KnockRequestItem( color = MaterialTheme.colorScheme.primary, style = ElementTheme.typography.fontBodyLgMedium, ) - if (!knockRequest.formattedDate.isNullOrEmpty()) { + val formattedDate = knockRequest.formattedDate + if (!formattedDate.isNullOrEmpty()) { Spacer(modifier = Modifier.width(8.dp)) Text( - text = knockRequest.formattedDate, + text = formattedDate, color = MaterialTheme.colorScheme.secondary, style = ElementTheme.typography.fontBodySmRegular, ) @@ -272,7 +355,8 @@ private fun KnockRequestItem( ) } // Reason - if (!knockRequest.reason.isNullOrBlank()) { + val reason = knockRequest.reason + if (!reason.isNullOrBlank()) { Spacer(modifier = Modifier.height(12.dp)) var isExpanded by rememberSaveable(knockRequest.userId) { mutableStateOf(false) } var isExpandable by rememberSaveable(knockRequest.userId) { mutableStateOf(false) } @@ -283,7 +367,7 @@ private fun KnockRequestItem( .clickable(enabled = isExpandable) { isExpanded = !isExpanded } ) { Text( - text = knockRequest.reason, + text = reason, style = ElementTheme.typography.fontBodyMdRegular, maxLines = if (isExpanded) Int.MAX_VALUE else 3, onTextLayout = { result -> @@ -336,7 +420,7 @@ private fun KnockRequestItem( TextButton( text = stringResource(R.string.screen_knock_requests_list_decline_and_ban_action_title), onClick = { - onAcceptClick(knockRequest) + onBanClick(knockRequest) }, destructive = true, size = ButtonSize.Small, diff --git a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenterTest.kt b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenterTest.kt new file mode 100644 index 0000000000..7027061804 --- /dev/null +++ b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenterTest.kt @@ -0,0 +1,243 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.banner + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions +import io.element.android.features.knockrequests.impl.data.KnockRequestsService +import io.element.android.libraries.matrix.api.room.knock.KnockRequest +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_ID_2 +import io.element.android.libraries.matrix.test.A_USER_ID_3 +import io.element.android.libraries.matrix.test.room.knock.FakeKnockRequest +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) class KnockRequestsBannerPresenterTest { + @Test + fun `present - when feature is disabled then the banner should be hidden`() = runTest { + val knockRequests = flowOf(listOf(FakeKnockRequest())) + val presenter = createKnockRequestsBannerPresenter(isFeatureEnabled = false, knockRequestsFlow = knockRequests) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.isVisible).isFalse() + } + } + } + + @Test + fun `present - when empty knock request list then the banner should be hidden`() = runTest { + val knockRequests = flowOf(emptyList()) + val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.isVisible).isFalse() + } + } + } + + @Test + fun `present - when no permission to manage knock requests then the banner should be hidden`() = runTest { + val presenter = createKnockRequestsBannerPresenter(canAcceptKnockRequests = false) + presenter.test { + awaitItem().also { state -> + assertThat(state.isVisible).isFalse() + } + } + } + + @Test + fun `present - when everything is setup to manage knocks with data, then the banner should be visible`() = runTest { + val knockRequests = flowOf( + listOf( + FakeKnockRequest( + reason = "A reason", + ) + ) + ) + val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests) + presenter.test { + skipItems(2) + awaitItem().also { state -> + assertThat(state.isVisible).isTrue() + assertThat(state.knockRequests).hasSize(1) + assertThat(state.canAccept).isTrue() + assertThat(state.reason).isEqualTo("A reason") + } + } + } + + @Test + fun `present - when multiple knock requests, the banner should not have reason nor subtitle`() = runTest { + val knockRequests = flowOf( + listOf( + FakeKnockRequest( + displayName = "Alice", + ), + FakeKnockRequest( + displayName = "Bob", + ), + FakeKnockRequest( + displayName = "Charlie", + ), + ) + ) + val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests) + presenter.test { + skipItems(2) + awaitItem().also { state -> + assertThat(state.isVisible).isTrue() + assertThat(state.knockRequests).hasSize(3) + assertThat(state.reason).isNull() + assertThat(state.subtitle).isNull() + } + } + } + + @Test + fun `present - when there are some seen knock requests, then the banner should filtered them`() = runTest { + val knockRequests = flowOf( + listOf( + FakeKnockRequest( + displayName = "Alice", + isSeen = true, + userId = A_USER_ID + ), + FakeKnockRequest( + displayName = "Bob", + isSeen = true, + userId = A_USER_ID_2 + ), + FakeKnockRequest( + isSeen = false, + displayName = "Charlie", + reason = "A reason", + userId = A_USER_ID_3 + ), + ) + ) + val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests) + presenter.test { + skipItems(2) + awaitItem().also { state -> + assertThat(state.isVisible).isTrue() + // Only Charlie should be displayed + assertThat(state.knockRequests).hasSize(1) + assertThat(state.reason).isEqualTo("A reason") + assertThat(state.subtitle).isEqualTo(A_USER_ID_3.value) + } + } + } + + @Test + fun `present - given AcceptSingleRequest event with failure, then the banner should hide and reappear and error should appear and disappear`() = runTest { + val acceptLambda = lambdaRecorder> { Result.failure(Exception()) } + val knockRequest = FakeKnockRequest( + displayName = "Alice", + reason = "A reason", + acceptLambda = acceptLambda + ) + val knockRequests = flowOf(listOf(knockRequest)) + val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests) + presenter.test { + skipItems(2) + awaitItem().also { state -> + state.eventSink(KnockRequestsBannerEvents.AcceptSingleRequest) + } + awaitItem().also { state -> + assertThat(state.isVisible).isFalse() + assertThat(state.displayAcceptError).isFalse() + } + awaitItem().also { state -> + assertThat(state.isVisible).isFalse() + assertThat(state.displayAcceptError).isTrue() + } + awaitItem().also { state -> + assertThat(state.isVisible).isTrue() + assertThat(state.displayAcceptError).isTrue() + } + awaitItem().also { state -> + assertThat(state.isVisible).isTrue() + assertThat(state.displayAcceptError).isFalse() + } + assert(acceptLambda).isCalledOnce() + } + } + + @Test + fun `present - given an AcceptSingleRequest event with success, then banner should be dismissed`() = runTest { + val acceptLambda = lambdaRecorder> { Result.success(Unit) } + val knockRequest = FakeKnockRequest( + displayName = "Alice", + reason = "A reason", + acceptLambda = acceptLambda + ) + val knockRequests = flowOf(listOf(knockRequest)) + val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests) + presenter.test { + skipItems(2) + awaitItem().also { state -> + assertThat(state.knockRequests).hasSize(1) + state.eventSink(KnockRequestsBannerEvents.AcceptSingleRequest) + } + awaitItem().also { state -> + assertThat(state.isVisible).isFalse() + } + advanceUntilIdle() + assert(acceptLambda).isCalledOnce() + } + } + + @Test + fun `present - given a Dismiss event, then knock requests should be marked as seen`() = runTest { + val markAsSeenLambda = lambdaRecorder> { Result.success(Unit) } + val knockRequests = flowOf( + listOf( + FakeKnockRequest(markAsSeenLambda = markAsSeenLambda), + FakeKnockRequest(markAsSeenLambda = markAsSeenLambda), + FakeKnockRequest(markAsSeenLambda = markAsSeenLambda), + ) + ) + val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests) + presenter.test { + skipItems(2) + awaitItem().also { state -> + state.eventSink(KnockRequestsBannerEvents.Dismiss) + } + advanceUntilIdle() + assert(markAsSeenLambda).isCalledExactly(3) + } + } +} + +private fun TestScope.createKnockRequestsBannerPresenter( + knockRequestsFlow: Flow> = flowOf(emptyList()), + canAcceptKnockRequests: Boolean = true, + isFeatureEnabled: Boolean = true, +): KnockRequestsBannerPresenter { + val knockRequestsService = KnockRequestsService( + knockRequestsFlow = knockRequestsFlow, + coroutineScope = backgroundScope, + isKnockFeatureEnabledFlow = flowOf(isFeatureEnabled), + permissionsFlow = flowOf(KnockRequestPermissions(canAcceptKnockRequests, canAcceptKnockRequests, canAcceptKnockRequests)), + ) + return KnockRequestsBannerPresenter( + knockRequestsService = knockRequestsService, + appCoroutineScope = this, + ) +} diff --git a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerViewTest.kt b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerViewTest.kt new file mode 100644 index 0000000000..ec594086fc --- /dev/null +++ b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerViewTest.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.banner + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.knockrequests.impl.R +import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class KnockRequestsBannerViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `clicking on view on single request invoke the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setKnockRequestsBannerView( + state = aKnockRequestsBannerState( + eventSink = eventsRecorder, + ), + onViewRequestsClick = it + ) + rule.clickOn(R.string.screen_room_single_knock_request_view_button_title) + } + } + + @Test + fun `clicking on view all when multiple requests invoke the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setKnockRequestsBannerView( + state = aKnockRequestsBannerState( + knockRequests = listOf( + aKnockRequestPresentable(displayName = "Alice"), + aKnockRequestPresentable(displayName = "Bob"), + aKnockRequestPresentable(displayName = "Charlie") + ), + eventSink = eventsRecorder, + ), + onViewRequestsClick = it + ) + rule.clickOn(R.string.screen_room_multiple_knock_requests_view_all_button_title) + } + } + + @Test + fun `clicking on accept on a single request emit the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setKnockRequestsBannerView( + state = aKnockRequestsBannerState( + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_accept) + eventsRecorder.assertSingle(KnockRequestsBannerEvents.AcceptSingleRequest) + } + + @Test + fun `clicking on dismiss emit the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setKnockRequestsBannerView( + state = aKnockRequestsBannerState( + eventSink = eventsRecorder, + ), + ) + val close = rule.activity.getString(CommonStrings.action_close) + rule.onNodeWithContentDescription(close).performClick() + eventsRecorder.assertSingle(KnockRequestsBannerEvents.Dismiss) + } +} + +private fun AndroidComposeTestRule.setKnockRequestsBannerView( + state: KnockRequestsBannerState, + onViewRequestsClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + KnockRequestsBannerView( + state = state, + onViewRequestsClick = onViewRequestsClick, + ) + } +} diff --git a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenterTest.kt b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenterTest.kt new file mode 100644 index 0000000000..d74155ead1 --- /dev/null +++ b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenterTest.kt @@ -0,0 +1,304 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.knockrequests.impl.list + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions +import io.element.android.features.knockrequests.impl.data.KnockRequestsService +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.room.knock.KnockRequest +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 +import io.element.android.libraries.matrix.test.room.knock.FakeKnockRequest +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class KnockRequestsListPresenterTest { + @Test + fun `present - initial states should be emitted`() = runTest { + val presenter = createKnockRequestsListPresenter() + presenter.test { + awaitItem().also { state -> + assertThat(state.knockRequests).isInstanceOf(AsyncData.Loading::class.java) + assertThat(state.permissions.canAccept).isFalse() + assertThat(state.permissions.canDecline).isFalse() + assertThat(state.permissions.canBan).isFalse() + } + awaitItem().also { state -> + assertThat(state.knockRequests).isInstanceOf(AsyncData.Loading::class.java) + assertThat(state.permissions.canAccept).isTrue() + assertThat(state.permissions.canDecline).isTrue() + assertThat(state.permissions.canBan).isTrue() + } + awaitItem().also { state -> + assertThat(state.knockRequests).isInstanceOf(AsyncData.Success::class.java) + assertThat(state.knockRequests.dataOrNull()).isEmpty() + } + } + } + + @Test + fun `present - accept success scenario`() = runTest { + val acceptLambda = lambdaRecorder> { Result.success(Unit) } + val knockRequest = FakeKnockRequest(acceptLambda = acceptLambda) + val knockRequests = flowOf(listOf(knockRequest)) + val presenter = createKnockRequestsListPresenter( + knockRequestsFlow = knockRequests + ) + presenter.test { + skipItems(2) + awaitItem().also { state -> + val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!! + state.eventSink(KnockRequestsListEvents.Accept(knockRequestPresentable)) + } + skipItems(1) + awaitItem().also { state -> + val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!! + assertThat(state.currentAction).isEqualTo(KnockRequestsAction.Accept(knockRequestPresentable)) + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Success::class.java) + state.eventSink(KnockRequestsListEvents.ResetCurrentAction) + } + skipItems(2) + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + assertThat(state.currentAction).isEqualTo(KnockRequestsAction.None) + assertThat(state.knockRequests.dataOrNull().orEmpty()).isEmpty() + } + assert(acceptLambda).isCalledOnce() + } + } + + @Test + fun `present - accept failure scenario`() = runTest { + val acceptLambda = lambdaRecorder> { Result.failure(Exception()) } + val knockRequest = FakeKnockRequest(acceptLambda = acceptLambda) + val knockRequests = flowOf(listOf(knockRequest)) + val presenter = createKnockRequestsListPresenter( + knockRequestsFlow = knockRequests + ) + presenter.test { + skipItems(2) + awaitItem().also { state -> + val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!! + state.eventSink(KnockRequestsListEvents.Accept(knockRequestPresentable)) + } + skipItems(1) + awaitItem().also { state -> + val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!! + assertThat(state.currentAction).isEqualTo(KnockRequestsAction.Accept(knockRequestPresentable)) + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Failure::class.java) + state.eventSink(KnockRequestsListEvents.RetryCurrentAction) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Failure::class.java) + state.eventSink(KnockRequestsListEvents.ResetCurrentAction) + } + skipItems(1) + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + assertThat(state.currentAction).isEqualTo(KnockRequestsAction.None) + assertThat(state.knockRequests.dataOrNull()).hasSize(1) + } + assert(acceptLambda).isCalledExactly(2) + } + } + + @Test + fun `present - decline success scenario`() = runTest { + val declineLambda = lambdaRecorder> { Result.success(Unit) } + val knockRequest = FakeKnockRequest(declineLambda = declineLambda) + val knockRequests = flowOf(listOf(knockRequest)) + val presenter = createKnockRequestsListPresenter( + knockRequestsFlow = knockRequests + ) + presenter.test { + skipItems(2) + awaitItem().also { state -> + val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!! + state.eventSink(KnockRequestsListEvents.Decline(knockRequestPresentable)) + } + skipItems(1) + awaitItem().also { state -> + val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!! + assertThat(state.currentAction).isEqualTo(KnockRequestsAction.Decline(knockRequestPresentable)) + assertThat(state.asyncAction).isInstanceOf(AsyncAction.ConfirmingNoParams::class.java) + state.eventSink(KnockRequestsListEvents.ConfirmCurrentAction) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Success::class.java) + state.eventSink(KnockRequestsListEvents.ResetCurrentAction) + } + skipItems(2) + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + assertThat(state.currentAction).isEqualTo(KnockRequestsAction.None) + assertThat(state.knockRequests.dataOrNull().orEmpty()).isEmpty() + } + } + assert(declineLambda).isCalledOnce() + } + + @Test + fun `present - decline and ban success scenario`() = runTest { + val declineAndBanLambda = lambdaRecorder> { Result.success(Unit) } + val knockRequest = FakeKnockRequest(declineAndBanLambda = declineAndBanLambda) + val knockRequests = flowOf(listOf(knockRequest)) + val presenter = createKnockRequestsListPresenter( + knockRequestsFlow = knockRequests + ) + presenter.test { + skipItems(2) + awaitItem().also { state -> + val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!! + state.eventSink(KnockRequestsListEvents.DeclineAndBan(knockRequestPresentable)) + } + skipItems(1) + awaitItem().also { state -> + val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!! + assertThat(state.currentAction).isEqualTo(KnockRequestsAction.DeclineAndBan(knockRequestPresentable)) + assertThat(state.asyncAction).isInstanceOf(AsyncAction.ConfirmingNoParams::class.java) + state.eventSink(KnockRequestsListEvents.ConfirmCurrentAction) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Success::class.java) + state.eventSink(KnockRequestsListEvents.ResetCurrentAction) + } + skipItems(2) + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + assertThat(state.currentAction).isEqualTo(KnockRequestsAction.None) + assertThat(state.knockRequests.dataOrNull().orEmpty()).isEmpty() + } + } + assert(declineAndBanLambda).isCalledOnce() + } + + @Test + fun `present - accept all success scenario`() = runTest { + val acceptLambda = lambdaRecorder> { Result.success(Unit) } + val knockRequests = flowOf( + listOf( + FakeKnockRequest(eventId = AN_EVENT_ID, acceptLambda = acceptLambda), + FakeKnockRequest(eventId = AN_EVENT_ID_2, acceptLambda = acceptLambda), + ) + ) + val presenter = createKnockRequestsListPresenter( + knockRequestsFlow = knockRequests + ) + presenter.test { + skipItems(2) + awaitItem().also { state -> + assertThat(state.canAcceptAll).isTrue() + state.eventSink(KnockRequestsListEvents.AcceptAll) + } + skipItems(1) + awaitItem().also { state -> + assertThat(state.currentAction).isEqualTo(KnockRequestsAction.AcceptAll) + assertThat(state.asyncAction).isInstanceOf(AsyncAction.ConfirmingNoParams::class.java) + state.eventSink(KnockRequestsListEvents.ConfirmCurrentAction) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Success::class.java) + state.eventSink(KnockRequestsListEvents.ResetCurrentAction) + } + skipItems(2) + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + assertThat(state.currentAction).isEqualTo(KnockRequestsAction.None) + assertThat(state.knockRequests.dataOrNull().orEmpty()).isEmpty() + } + } + assert(acceptLambda).isCalledExactly(2) + } + + @Test + fun `present - accept all partial success scenario`() = runTest { + val acceptSuccessLambda = lambdaRecorder> { Result.success(Unit) } + val acceptFailureLambda = lambdaRecorder> { Result.failure(Exception()) } + val knockRequests = flowOf( + listOf( + FakeKnockRequest(eventId = AN_EVENT_ID, acceptLambda = acceptSuccessLambda), + FakeKnockRequest(eventId = AN_EVENT_ID_2, acceptLambda = acceptFailureLambda), + ) + ) + val presenter = createKnockRequestsListPresenter( + knockRequestsFlow = knockRequests + ) + presenter.test { + skipItems(2) + awaitItem().also { state -> + assertThat(state.canAcceptAll).isTrue() + state.eventSink(KnockRequestsListEvents.AcceptAll) + } + skipItems(1) + awaitItem().also { state -> + assertThat(state.currentAction).isEqualTo(KnockRequestsAction.AcceptAll) + assertThat(state.asyncAction).isInstanceOf(AsyncAction.ConfirmingNoParams::class.java) + state.eventSink(KnockRequestsListEvents.ConfirmCurrentAction) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Failure::class.java) + state.eventSink(KnockRequestsListEvents.ResetCurrentAction) + } + skipItems(2) + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + assertThat(state.currentAction).isEqualTo(KnockRequestsAction.None) + assertThat(state.knockRequests.dataOrNull()).hasSize(1) + } + } + assert(acceptFailureLambda).isCalledOnce() + assert(acceptSuccessLambda).isCalledOnce() + } + + private fun TestScope.createKnockRequestsListPresenter( + canAccept: Boolean = true, + canDecline: Boolean = true, + canBan: Boolean = true, + knockRequestsFlow: Flow> = flowOf(emptyList()) + ): KnockRequestsListPresenter { + val knockRequestsService = KnockRequestsService( + knockRequestsFlow = knockRequestsFlow, + coroutineScope = backgroundScope, + isKnockFeatureEnabledFlow = flowOf(true), + permissionsFlow = flowOf(KnockRequestPermissions(canAccept, canDecline, canBan)), + ) + return KnockRequestsListPresenter(knockRequestsService = knockRequestsService) + } +} diff --git a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListViewTest.kt b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListViewTest.kt new file mode 100644 index 0000000000..af2bfefd16 --- /dev/null +++ b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListViewTest.kt @@ -0,0 +1,163 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.list + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.knockrequests.impl.R +import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import kotlinx.collections.immutable.persistentListOf +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class KnockRequestsListViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on back invoke the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setKnockRequestsListView( + aKnockRequestsListState( + eventSink = eventsRecorder, + ), + onBackClick = it + ) + rule.pressBack() + } + } + + @Test + fun `clicking on accept emit the expected event`() { + val eventsRecorder = EventsRecorder() + val knockRequest = aKnockRequestPresentable() + rule.setKnockRequestsListView( + aKnockRequestsListState( + knockRequests = AsyncData.Success(persistentListOf(knockRequest)), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_accept) + eventsRecorder.assertSingle(KnockRequestsListEvents.Accept(knockRequest)) + } + + @Test + fun `clicking on decline emit the expected event`() { + val eventsRecorder = EventsRecorder() + val knockRequest = aKnockRequestPresentable() + rule.setKnockRequestsListView( + aKnockRequestsListState( + knockRequests = AsyncData.Success(persistentListOf(knockRequest)), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_decline) + eventsRecorder.assertSingle(KnockRequestsListEvents.Decline(knockRequest)) + } + + @Test + fun `clicking on decline and ban emit the expected event`() { + val eventsRecorder = EventsRecorder() + val knockRequest = aKnockRequestPresentable() + rule.setKnockRequestsListView( + aKnockRequestsListState( + knockRequests = AsyncData.Success(persistentListOf(knockRequest)), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(R.string.screen_knock_requests_list_decline_and_ban_action_title) + eventsRecorder.assertSingle(KnockRequestsListEvents.DeclineAndBan(knockRequest)) + } + + @Test + fun `clicking on accept all emit the expected event`() { + val eventsRecorder = EventsRecorder() + val knockRequests = persistentListOf(aKnockRequestPresentable(), aKnockRequestPresentable()) + rule.setKnockRequestsListView( + aKnockRequestsListState( + knockRequests = AsyncData.Success(knockRequests), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(R.string.screen_knock_requests_list_accept_all_button_title) + eventsRecorder.assertSingle(KnockRequestsListEvents.AcceptAll) + } + + @Test + fun `retry on async view retry emit the expected event`() { + val eventsRecorder = EventsRecorder() + val knockRequests = persistentListOf(aKnockRequestPresentable(), aKnockRequestPresentable()) + rule.setKnockRequestsListView( + aKnockRequestsListState( + knockRequests = AsyncData.Success(knockRequests), + asyncAction = AsyncAction.Failure(Throwable("Failed to accept all")), + currentAction = KnockRequestsAction.AcceptAll, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_retry) + eventsRecorder.assertSingle(KnockRequestsListEvents.RetryCurrentAction) + } + + @Test + fun `canceling async view emit the expected event`() { + val eventsRecorder = EventsRecorder() + val knockRequests = persistentListOf(aKnockRequestPresentable(), aKnockRequestPresentable()) + rule.setKnockRequestsListView( + aKnockRequestsListState( + knockRequests = AsyncData.Success(knockRequests), + asyncAction = AsyncAction.Failure(Throwable("Failed to accept all")), + currentAction = KnockRequestsAction.AcceptAll, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(KnockRequestsListEvents.ResetCurrentAction) + } + + @Test + fun `confirming async view emit the expected event`() { + val eventsRecorder = EventsRecorder() + val knockRequests = persistentListOf(aKnockRequestPresentable(), aKnockRequestPresentable()) + rule.setKnockRequestsListView( + aKnockRequestsListState( + knockRequests = AsyncData.Success(knockRequests), + asyncAction = AsyncAction.ConfirmingNoParams, + currentAction = KnockRequestsAction.AcceptAll, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(R.string.screen_knock_requests_list_accept_all_alert_confirm_button_title) + eventsRecorder.assertSingle(KnockRequestsListEvents.ConfirmCurrentAction) + } +} + +private fun AndroidComposeTestRule.setKnockRequestsListView( + state: KnockRequestsListState, + onBackClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + KnockRequestsListView( + state = state, + onBackClick = onBackClick, + ) + } +} 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 d55f4e4f7d..50c403fe87 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 @@ -36,6 +36,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.isDm +import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.room.powerlevels.canInvite import io.element.android.libraries.matrix.api.room.powerlevels.canSendState import io.element.android.libraries.matrix.api.room.roomNotificationSettings @@ -77,7 +78,7 @@ class RoomDetailsPresenter @Inject constructor( val roomName by remember { derivedStateOf { (roomInfo?.name ?: room.displayName).trim() } } val roomTopic by remember { derivedStateOf { roomInfo?.topic ?: room.topic } } val isFavorite by remember { derivedStateOf { roomInfo?.isFavorite.orFalse() } } - val isPublic by remember { derivedStateOf { roomInfo?.isPublic.orFalse() } } + val joinRule by remember { derivedStateOf { roomInfo?.joinRule } } val canShowPinnedMessages = isPinnedMessagesFeatureEnabled() var canShowMediaGallery by remember { mutableStateOf(false) } @@ -106,11 +107,8 @@ class RoomDetailsPresenter @Inject constructor( val roomType by getRoomType(dmMember, currentMember) val roomCallState = roomCallStatePresenter.present() - val canHandleKnockRequests by room.canHandleKnockRequestsAsState(syncUpdateFlow.value) - val topicState = remember(canEditTopic, roomTopic, roomType) { val topic = roomTopic - when { !topic.isNullOrBlank() -> RoomTopicState.ExistingTopic(topic) canEditTopic && roomType is RoomDetailsType.Room -> RoomTopicState.CanAddTopic @@ -118,10 +116,13 @@ class RoomDetailsPresenter @Inject constructor( } } + val canHandleKnockRequests by room.canHandleKnockRequestsAsState(syncUpdateFlow.value) val isKnockRequestsEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock).collectAsState(false) - val knockRequestsCount by remember { mutableStateOf(null) } + val knockRequestsCount by produceState(null) { + room.knockRequestsFlow.collect { value = it.size } + } val canShowKnockRequests by remember { - derivedStateOf { isKnockRequestsEnabled && canHandleKnockRequests } + derivedStateOf { isKnockRequestsEnabled && canHandleKnockRequests && joinRule == JoinRule.Knock } } val roomNotificationSettingsState by room.roomNotificationSettingsStateFlow.collectAsState() @@ -164,7 +165,7 @@ class RoomDetailsPresenter @Inject constructor( roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(), isFavorite = isFavorite, displayRolesAndPermissionsSettings = !room.isDm && isUserAdmin, - isPublic = isPublic, + isPublic = joinRule == JoinRule.Public, heroes = roomInfo?.heroes.orEmpty().toPersistentList(), canShowPinnedMessages = canShowPinnedMessages, canShowMediaGallery = canShowMediaGallery, diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt index 45309f5d88..8dbd78fab3 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt @@ -24,6 +24,7 @@ import io.element.android.libraries.matrix.api.media.MediaUploadHandler import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.room.draft.ComposerDraft +import io.element.android.libraries.matrix.api.room.knock.KnockRequest import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange @@ -32,6 +33,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import java.io.Closeable @@ -52,10 +54,17 @@ interface MatrixRoom : Closeable { val activeMemberCount: Long val joinedMemberCount: Long + val roomCoroutineScope: CoroutineScope + val roomInfoFlow: Flow val roomTypingMembersFlow: Flow> val identityStateChangesFlow: Flow> + /** + * The current knock requests in the room as a Flow. + */ + val knockRequestsFlow: Flow> + /** * A one-to-one is a room with exactly 2 members. * See [the Matrix spec](https://spec.matrix.org/latest/client-server-api/#default-underride-rules). diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomInfo.kt index 6105a59c38..825ff85e96 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomInfo.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomInfo.kt @@ -12,6 +12,7 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomAlias 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.room.join.JoinRule import io.element.android.libraries.matrix.api.user.MatrixUser import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap @@ -27,6 +28,7 @@ data class MatrixRoomInfo( val avatarUrl: String?, val isDirect: Boolean, val isPublic: Boolean, + val joinRule: JoinRule?, val isSpace: Boolean, val isTombstoned: Boolean, val isFavorite: Boolean, diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/join/AllowRule.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/join/AllowRule.kt new file mode 100644 index 0000000000..2ba4893ec8 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/join/AllowRule.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room.join + +import io.element.android.libraries.matrix.api.core.RoomId + +sealed interface AllowRule { + data class RoomMembership(val roomId: RoomId) : AllowRule + data class Custom(val json: String) : AllowRule +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/join/JoinRule.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/join/JoinRule.kt new file mode 100644 index 0000000000..b597fcf781 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/join/JoinRule.kt @@ -0,0 +1,18 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room.join + +sealed interface JoinRule { + data object Public : JoinRule + data object Private : JoinRule + data object Knock : JoinRule + data object Invite : JoinRule + data class Restricted(val rules: List) : JoinRule + data class KnockRestricted(val rules: List) : JoinRule + data class Custom(val value: String) : JoinRule +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/knock/KnockRequest.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/knock/KnockRequest.kt new file mode 100644 index 0000000000..e7a0488245 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/knock/KnockRequest.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room.knock + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId + +interface KnockRequest { + val eventId: EventId + val userId: UserId + val displayName: String? + val avatarUrl: String? + val reason: String? + val timestamp: Long? + val isSeen: Boolean + + suspend fun accept(): Result + + suspend fun decline(reason: String?): Result + + suspend fun declineAndBan(reason: String?): Result + + suspend fun markAsSeen(): Result +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MatrixRoomInfoMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MatrixRoomInfoMapper.kt index 4480c8d82d..029f6ae508 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MatrixRoomInfoMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MatrixRoomInfoMapper.kt @@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.MatrixRoomInfo import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.impl.room.join.map import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.toImmutableList @@ -36,6 +37,7 @@ class MatrixRoomInfoMapper { avatarUrl = it.avatarUrl, isDirect = it.isDirect, isPublic = it.isPublic, + joinRule = it.joinRule?.map(), isSpace = it.isSpace, isTombstoned = it.isTombstoned, isFavorite = it.isFavourite, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index e672cf9394..646ab8290a 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -38,6 +38,7 @@ import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.draft.ComposerDraft +import io.element.android.libraries.matrix.api.room.knock.KnockRequest import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange @@ -50,6 +51,7 @@ import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings import io.element.android.libraries.matrix.impl.core.RustSendHandle import io.element.android.libraries.matrix.impl.mapper.map import io.element.android.libraries.matrix.impl.room.draft.into +import io.element.android.libraries.matrix.impl.room.knock.RustKnockRequest import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper import io.element.android.libraries.matrix.impl.room.powerlevels.RoomPowerLevelsMapper @@ -76,6 +78,7 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.DateDividerMode import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener +import org.matrix.rustcomponents.sdk.KnockRequestsListener import org.matrix.rustcomponents.sdk.RoomInfo import org.matrix.rustcomponents.sdk.RoomInfoListener import org.matrix.rustcomponents.sdk.RoomListItem @@ -91,6 +94,7 @@ import uniffi.matrix_sdk.RoomPowerLevelChanges import java.io.File import kotlin.coroutines.cancellation.CancellationException import org.matrix.rustcomponents.sdk.IdentityStatusChange as RustIdentityStateChange +import org.matrix.rustcomponents.sdk.KnockRequest as InnerKnockRequest import org.matrix.rustcomponents.sdk.Room as InnerRoom import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline @@ -157,13 +161,22 @@ class RustMatrixRoom( }) } + override val knockRequestsFlow: Flow> = mxCallbackFlow { + innerRoom.subscribeToKnockRequests(object : KnockRequestsListener { + override fun call(joinRequests: List) { + val knockRequests = joinRequests.map { RustKnockRequest(it) } + channel.trySend(knockRequests) + } + }) + } + // Create a dispatcher for all room methods... private val roomDispatcher = coroutineDispatchers.io.limitedParallelism(32) // ...except getMember methods as it could quickly fill the roomDispatcher... private val roomMembersDispatcher = coroutineDispatchers.io.limitedParallelism(8) - private val roomCoroutineScope = sessionCoroutineScope.childScope(coroutineDispatchers.main, "RoomScope-$roomId") + override val roomCoroutineScope = sessionCoroutineScope.childScope(coroutineDispatchers.main, "RoomScope-$roomId") private val _syncUpdateFlow = MutableStateFlow(0L) private val roomMemberListFetcher = RoomMemberListFetcher(innerRoom, roomMembersDispatcher) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/join/AllowRule.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/join/AllowRule.kt new file mode 100644 index 0000000000..86f37c5f20 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/join/AllowRule.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room.join + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.join.AllowRule +import org.matrix.rustcomponents.sdk.AllowRule as RustAllowRule + +fun RustAllowRule.map(): AllowRule { + return when (this) { + is RustAllowRule.RoomMembership -> AllowRule.RoomMembership(RoomId(roomId)) + is RustAllowRule.Custom -> AllowRule.Custom(json) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/join/JoinRule.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/join/JoinRule.kt new file mode 100644 index 0000000000..f5c65c7283 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/join/JoinRule.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room.join + +import io.element.android.libraries.matrix.api.room.join.JoinRule +import org.matrix.rustcomponents.sdk.JoinRule as RustJoinRule + +fun RustJoinRule.map(): JoinRule { + return when (this) { + RustJoinRule.Public -> JoinRule.Public + RustJoinRule.Private -> JoinRule.Private + RustJoinRule.Knock -> JoinRule.Knock + RustJoinRule.Invite -> JoinRule.Invite + is RustJoinRule.Restricted -> JoinRule.Restricted(rules.map { it.map() }) + is RustJoinRule.Custom -> JoinRule.Custom(repr) + is RustJoinRule.KnockRestricted -> JoinRule.KnockRestricted(rules.map { it.map() }) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/knock/RustKnockRequest.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/knock/RustKnockRequest.kt new file mode 100644 index 0000000000..9e12866c9c --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/knock/RustKnockRequest.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room.knock + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.knock.KnockRequest +import org.matrix.rustcomponents.sdk.KnockRequest as InnerKnockRequest + +class RustKnockRequest( + private val inner: InnerKnockRequest, +) : KnockRequest { + override val eventId: EventId = EventId(inner.eventId) + override val userId: UserId = UserId(inner.userId) + override val displayName: String? = inner.displayName + override val avatarUrl: String? = inner.avatarUrl + override val reason: String? = inner.reason + override val timestamp: Long? = inner.timestamp?.toLong() + override val isSeen: Boolean = inner.isSeen + + override suspend fun accept(): Result = runCatching { + inner.actions.accept() + } + + override suspend fun decline(reason: String?): Result = runCatching { + inner.actions.decline(reason) + } + + override suspend fun declineAndBan(reason: String?): Result = runCatching { + inner.actions.declineAndBan(reason) + } + + override suspend fun markAsSeen(): Result = runCatching { + inner.actions.markAsSeen() + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/MatrixRoomInfoMapperTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/MatrixRoomInfoMapperTest.kt index d2fa714880..f277b26bae 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/MatrixRoomInfoMapperTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/MatrixRoomInfoMapperTest.kt @@ -14,6 +14,7 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.MatrixRoomInfo import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomHero import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomInfo @@ -31,6 +32,7 @@ import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toPersistentList import org.junit.Test import org.matrix.rustcomponents.sdk.Membership +import org.matrix.rustcomponents.sdk.JoinRule as RustJoinRule import org.matrix.rustcomponents.sdk.RoomNotificationMode as RustRoomNotificationMode class MatrixRoomInfoMapperTest { @@ -47,6 +49,7 @@ class MatrixRoomInfoMapperTest { isDirect = true, isPublic = false, isSpace = false, + joinRule = RustJoinRule.Invite, isTombstoned = false, isFavourite = false, canonicalAlias = A_ROOM_ALIAS.value, @@ -83,6 +86,7 @@ class MatrixRoomInfoMapperTest { isSpace = false, isTombstoned = false, isFavorite = false, + joinRule = JoinRule.Invite, canonicalAlias = A_ROOM_ALIAS, alternativeAliases = listOf(A_ROOM_ALIAS).toImmutableList(), currentUserMembership = CurrentUserMembership.JOINED, @@ -125,6 +129,7 @@ class MatrixRoomInfoMapperTest { avatarUrl = null, isDirect = false, isPublic = true, + joinRule = null, isSpace = false, isTombstoned = false, isFavourite = true, @@ -159,6 +164,7 @@ class MatrixRoomInfoMapperTest { avatarUrl = null, isDirect = false, isPublic = true, + joinRule = null, isSpace = false, isTombstoned = false, isFavorite = true, diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index c47f4238b9..bea207eb8a 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -33,6 +33,7 @@ import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.draft.ComposerDraft +import io.element.android.libraries.matrix.api.room.knock.KnockRequest import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange @@ -48,12 +49,14 @@ import io.element.android.libraries.matrix.test.notificationsettings.FakeNotific import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.simulateLongTask +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.test.TestScope import java.io.File class FakeMatrixRoom( @@ -72,6 +75,7 @@ class FakeMatrixRoom( override val activeMemberCount: Long = 234L, val notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(), override val liveTimeline: Timeline = FakeTimeline(), + override val roomCoroutineScope: CoroutineScope = TestScope(), private var roomPermalinkResult: () -> Result = { lambdaError() }, private var eventPermalinkResult: (EventId) -> Result = { lambdaError() }, private val sendCallNotificationIfNeededResult: () -> Result = { lambdaError() }, @@ -163,6 +167,13 @@ class FakeMatrixRoom( _identityStateChangesFlow.tryEmit(identityStateChanges) } + private val _knockRequestsFlow: MutableSharedFlow> = MutableSharedFlow(replay = 1) + override val knockRequestsFlow: Flow> = _knockRequestsFlow + + fun emitKnockRequests(knockRequests: List) { + _knockRequestsFlow.tryEmit(knockRequests) + } + override val membersStateFlow: MutableStateFlow = MutableStateFlow(MatrixRoomMembersState.Unknown) override val roomNotificationSettingsStateFlow: MutableStateFlow = diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomInfoFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomInfoFixture.kt index 5aae1a3258..c05c24e7de 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomInfoFixture.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomInfoFixture.kt @@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.MatrixRoomInfo import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.A_ROOM_ID @@ -33,6 +34,7 @@ fun aRoomInfo( avatarUrl: String? = AN_AVATAR_URL, isDirect: Boolean = false, isPublic: Boolean = true, + joinRule: JoinRule? = JoinRule.Public, isSpace: Boolean = false, isTombstoned: Boolean = false, isFavorite: Boolean = false, @@ -64,6 +66,7 @@ fun aRoomInfo( avatarUrl = avatarUrl, isDirect = isDirect, isPublic = isPublic, + joinRule = joinRule, isSpace = isSpace, isTombstoned = isTombstoned, isFavorite = isFavorite, diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt index 39e13cb619..cd612f5ec1 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt @@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.MatrixRoomInfo import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.room.message.RoomMessage import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem @@ -46,6 +47,7 @@ fun aRoomSummary( avatarUrl: String? = null, isDirect: Boolean = false, isPublic: Boolean = true, + joinRule: JoinRule? = JoinRule.Public, isSpace: Boolean = false, isTombstoned: Boolean = false, isFavorite: Boolean = false, @@ -79,6 +81,7 @@ fun aRoomSummary( avatarUrl = avatarUrl, isDirect = isDirect, isPublic = isPublic, + joinRule = joinRule, isSpace = isSpace, isTombstoned = isTombstoned, isFavorite = isFavorite, diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/knock/FakeKnockRequest.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/knock/FakeKnockRequest.kt new file mode 100644 index 0000000000..416feae8b8 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/knock/FakeKnockRequest.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.room.knock + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.knock.KnockRequest +import io.element.android.libraries.matrix.test.AN_AVATAR_URL +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_NAME +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask + +class FakeKnockRequest( + override val eventId: EventId = AN_EVENT_ID, + override val userId: UserId = A_USER_ID, + override val displayName: String? = A_USER_NAME, + override val avatarUrl: String? = AN_AVATAR_URL, + override val reason: String? = null, + override val timestamp: Long? = null, + override val isSeen: Boolean = false, + val acceptLambda: () -> Result = { lambdaError() }, + val declineLambda: (String?) -> Result = { lambdaError() }, + val declineAndBanLambda: (String?) -> Result = { lambdaError() }, + val markAsSeenLambda: () -> Result = { lambdaError() }, +) : KnockRequest { + override suspend fun accept(): Result = simulateLongTask { + acceptLambda() + } + + override suspend fun decline(reason: String?): Result = simulateLongTask { + declineLambda(reason) + } + + override suspend fun declineAndBan(reason: String?): Result = simulateLongTask { + declineAndBanLambda(reason) + } + + override suspend fun markAsSeen(): Result = simulateLongTask { + markAsSeenLambda() + } +} diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Day_0_en.png index 8d01d01fbd..6866b6afc0 100644 --- a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:77db58461ef35ea3d1ab1dda6aff454e376b15d7037e83fbbd965037afa45572 -size 29317 +oid sha256:74f93deb90501b746d95e0edf2ae2cef58036a388888e42a9b0fd8aadac9758c +size 29447 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Day_1_en.png index 4441fd8ef3..9298210927 100644 --- a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:585ea7b1f230ac6a12f8098200b04f433cbb30b39b8be7953dc6e278ffe8179e -size 34799 +oid sha256:6a9a248862bd05327e10481f868c6dc10cc1ccad6a56284d497a9fb45f737206 +size 35042 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Day_2_en.png index 75cafbe376..a5e66c073f 100644 --- a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:05770e4e11bbc7019ebc113d31eb8b76bbbddc8b1ca6acbb01c0764089047376 -size 17835 +oid sha256:ef72766061f056096fc6d030b0e12d05dfa767ea5cac45d71b02cdbd0e78971b +size 17859 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Day_4_en.png index 55bf403a0d..8eef415284 100644 --- a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a10ccd7db4660bdff998622f5390a33adcaa39472c7d60ebae7c0a5b30a810d4 -size 27294 +oid sha256:3b98dc51e1f31bcd64dee223be7beb66b6ddd814189f290876633f5727d09f18 +size 27383 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Day_5_en.png index 8d01d01fbd..462b3bc35e 100644 --- a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:77db58461ef35ea3d1ab1dda6aff454e376b15d7037e83fbbd965037afa45572 -size 29317 +oid sha256:a386397f9a0f037050797980a3bd9482ec8a2f1a37d5e150ed8cdd50e5b576a1 +size 31873 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Day_6_en.png index 8d01d01fbd..ab44725d4c 100644 --- a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:77db58461ef35ea3d1ab1dda6aff454e376b15d7037e83fbbd965037afa45572 -size 29317 +oid sha256:1b0d57fed7af1716e23f9f1167f70199f5a21f60f3ac97940deccd824754a901 +size 39156 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Day_7_en.png deleted file mode 100644 index ab52d1d019..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Day_7_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:43e80c40ea0b0f6c02d03cf37537fa9e1b8ac71ec5f1d279b51477538a743063 -size 39209 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Night_0_en.png index fd40cbce60..29ea2f7789 100644 --- a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3f2c03edb4eaec4917b95955ac0b51cbdce25ae9710757cad68e3ed863707f7a -size 27374 +oid sha256:1a7c6898ea6667e5b7f09ee99ca6b2d75ae3bc20e0db57b9d7a20c48d4681dca +size 27308 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Night_1_en.png index 8c1e0742e5..43684248f2 100644 --- a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3811a02bebe20381a5996bcb61176d258fbb1ca4302cf5f9742470c3f152c780 -size 32258 +oid sha256:e229ac5d069412a18ae7deeefd0e88d9897b239ab32a0edd2c2d06f139f1f1af +size 32430 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Night_2_en.png index ca5bf4cb5f..31c1d7e220 100644 --- a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9aaf05a0f912cedf6897fac21e679429b86b0ed1d3e8b6a07cfe7ec6f60cec13 -size 15885 +oid sha256:62cf8a40ea8670ce9a23923d5791137ec057b130cde637c4dfb5b0edc75d7971 +size 15926 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Night_4_en.png index 38ace64640..02fd5af4b3 100644 --- a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:17d3263b1c47a75083bd597f73baf8cf5faa74274810e85cf2ee086812ca0e60 -size 25248 +oid sha256:89fdc1cf49c756e40d81eb858b507f751a6ff661fcb4f3194a5d7c0f738c1b94 +size 25250 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Night_5_en.png index fd40cbce60..6abdd712c0 100644 --- a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3f2c03edb4eaec4917b95955ac0b51cbdce25ae9710757cad68e3ed863707f7a -size 27374 +oid sha256:ad9532960a251f04ec0fd513df561010a91923ab41de8318f99df23d3799feec +size 28232 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Night_6_en.png index fd40cbce60..de9b3caf77 100644 --- a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Night_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Night_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3f2c03edb4eaec4917b95955ac0b51cbdce25ae9710757cad68e3ed863707f7a -size 27374 +oid sha256:8f2419c455efb74f0f528017e411c16d76755646e3449ae4a62077857e3ea93b +size 36848 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Night_7_en.png deleted file mode 100644 index 81355d2bfc..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.banner_KnockRequestsBannerView_Night_7_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:cbed125bd5c334bb5d7fce60df615d188c713989bcd6450f59d3f37973ab2bc7 -size 36735 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_0_en.png index d2ddc2c7c6..8b36ceefb5 100644 --- a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fbbd687e5e0a1fd3a1be442937e32990dc72a6e4817d6ffc29f6f596eeddef1b -size 8086 +oid sha256:0132466eb104c2249958a310b19336f97a5485f98e47a233c29740b14b9907ee +size 14298 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_10_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_10_en.png new file mode 100644 index 0000000000..8f0414d4b3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_10_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9afb58cfcd13c064f0b17d449318d5111aaa914cb8875fe0596c66e5a8a17051 +size 30647 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_5_en.png index c14ef5dd52..9463263a2f 100644 --- a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f7e66d3f7d10e759f629795580545cda6644292020ce36763d07e9d476dcc231 -size 30325 +oid sha256:dcd89988740d57132796306568378471b67ceb7c182e82fce5fa01dc936a640f +size 44821 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_6_en.png index 466e591450..a8e76f0ab7 100644 --- a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:586021e67718aa949b1de04799a43d9da8189fadacdb6dba23405c762fc7ee06 -size 30618 +oid sha256:7ee73fabc6f4db8b7fb26b606723c69cf169e3a003a828b74654b6286235e164 +size 34430 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_7_en.png index 2952c5bd81..c4e4262ce2 100644 --- a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1b71dc6dc0d29801e81d1d4e52df811624583f69c0510f2f650676747a326455 -size 30296 +oid sha256:170e1d045120d172d26d5d7bdd6bbf42c1a5bddcad7ff38d5352cd38c05efed4 +size 39551 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_8_en.png index 9c47af83a4..2952c5bd81 100644 --- a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f6be2cfe7991bce6c622f4f2e45f4ac96810cdc9462d9a4d7aea11baf69ab5ac -size 27446 +oid sha256:1b71dc6dc0d29801e81d1d4e52df811624583f69c0510f2f650676747a326455 +size 30296 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_9_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_9_en.png index 8f0414d4b3..9c47af83a4 100644 --- a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_9_en.png +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9afb58cfcd13c064f0b17d449318d5111aaa914cb8875fe0596c66e5a8a17051 -size 30647 +oid sha256:f6be2cfe7991bce6c622f4f2e45f4ac96810cdc9462d9a4d7aea11baf69ab5ac +size 27446 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_0_en.png index 5188e40e69..5ea469c53f 100644 --- a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7ff4bc9e0588ea6a8396572b9ac9fb2401c3193fc3f1fbeb75efd69be6dda24d -size 7867 +oid sha256:ab53aa77207ee6984eb5a7a619b5b748178819bd2006ace4282f28f1c6b16cac +size 13944 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_10_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_10_en.png new file mode 100644 index 0000000000..54a5fc7d9c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_10_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:68294db4cbd8bb42f4a0b6534b48585341beb2bc405e93cf74a2655f049522ff +size 29768 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_5_en.png index 45b2a829ef..6fec3fbf9a 100644 --- a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c30b6e25ec148d4f98a6c305b7d71af6c7e413cc3425f5a6f5b42a201039f491 -size 29007 +oid sha256:00c25643adf7f06ef46e824aa60b35348c18e596a82557b1546b55d29392aa11 +size 42613 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_6_en.png index b39ccb0150..d626612346 100644 --- a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e5c6abc8bd5ca40eb91d926adc798cee95a8aacb0e87d937983c6240173fe2b9 -size 30071 +oid sha256:ad43b5aaa3ea7cf3ab79af88a2a93ba4294527ad2c40c72f4312f3e1b92b4f1a +size 33280 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_7_en.png index 215cef276c..3cbbf256ec 100644 --- a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3b67c8d2cedb724ece75a2171463c4e035d562c4480d39b11b1072f4fdae3053 -size 29840 +oid sha256:6d9f612c2e6d46ed59ec5a3d1c65fe8953e4fc8f0955bddaa2abef6e1eabb00d +size 37194 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_8_en.png index 171b5f5aa5..215cef276c 100644 --- a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:15903c9494100becc4dd2bc6e9fb7f38f8eebbb595cbc251cfc0f839d9a99a27 -size 27322 +oid sha256:3b67c8d2cedb724ece75a2171463c4e035d562c4480d39b11b1072f4fdae3053 +size 29840 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_9_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_9_en.png index 54a5fc7d9c..171b5f5aa5 100644 --- a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_9_en.png +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:68294db4cbd8bb42f4a0b6534b48585341beb2bc405e93cf74a2655f049522ff -size 29768 +oid sha256:15903c9494100becc4dd2bc6e9fb7f38f8eebbb595cbc251cfc0f839d9a99a27 +size 27322