Merge pull request #4067 from element-hq/feature/fga/knock_requests_sdk

feat(knock requests) : branch logic for handling knock requests
This commit is contained in:
ganfra 2024-12-20 10:35:21 +01:00 committed by GitHub
commit f52e0f4cc1
66 changed files with 1878 additions and 317 deletions

View file

@ -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)
}

View file

@ -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,
)

View file

@ -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<KnockRequestsBannerState> {
private const val ACCEPT_ERROR_DISPLAY_DURATION = 1500L
class KnockRequestsBannerPresenter @Inject constructor(
private val knockRequestsService: KnockRequestsService,
private val appCoroutineScope: CoroutineScope,
) : Presenter<KnockRequestsBannerState> {
@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<KnockRequestPresentable>,
displayAcceptError: MutableState<Boolean>,
) = 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
}
}
}
}

View file

@ -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<KnockRequest>,
val acceptAction: AsyncAction<Unit>,
val knockRequests: ImmutableList<KnockRequestPresentable>,
val displayAcceptError: Boolean,
val canAccept: Boolean,
val eventSink: (KnockRequestsBannerEvents) -> Unit,
) {

View file

@ -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<KnockRequestsBannerState> {
@ -19,7 +18,7 @@ class KnockRequestsBannerStateProvider : PreviewParameterProvider<KnockRequestsB
aKnockRequestsBannerState(),
aKnockRequestsBannerState(
knockRequests = listOf(
aKnockRequest(
aKnockRequestPresentable(
reason = "A very long reason that should probably be truncated, " +
"but could be also expanded so you can see it over the lines, wow," +
"very amazing reason, I know, right, I'm so good at writing reasons."
@ -28,30 +27,27 @@ class KnockRequestsBannerStateProvider : PreviewParameterProvider<KnockRequestsB
),
aKnockRequestsBannerState(
knockRequests = listOf(
aKnockRequest(),
aKnockRequest(displayName = "Alice")
aKnockRequestPresentable(),
aKnockRequestPresentable(displayName = "Alice")
)
),
aKnockRequestsBannerState(
knockRequests = listOf(
aKnockRequest(),
aKnockRequest(displayName = "Alice"),
aKnockRequest(displayName = "Bob"),
aKnockRequest(displayName = "Charlie")
aKnockRequestPresentable(),
aKnockRequestPresentable(displayName = "Alice"),
aKnockRequestPresentable(displayName = "Bob"),
aKnockRequestPresentable(displayName = "Charlie")
)
),
aKnockRequestsBannerState(
canAccept = false
),
aKnockRequestsBannerState(
acceptAction = AsyncAction.Loading
),
aKnockRequestsBannerState(
acceptAction = AsyncAction.Failure(Throwable("Failed to accept knock"))
displayAcceptError = true
),
aKnockRequestsBannerState(
knockRequests = listOf(
aKnockRequest(
aKnockRequestPresentable(
displayName = "A_very_long_display_name_so_that_the_text_can_be_displayed_on_multiple_lines"
)
)
@ -60,14 +56,14 @@ class KnockRequestsBannerStateProvider : PreviewParameterProvider<KnockRequestsB
}
fun aKnockRequestsBannerState(
knockRequests: List<KnockRequest> = listOf(aKnockRequest()),
acceptAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
knockRequests: List<KnockRequestPresentable> = 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,

View file

@ -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<KnockRequest>,
knockRequests: ImmutableList<KnockRequestPresentable>,
modifier: Modifier = Modifier,
) {
Box(modifier) {
@ -183,7 +209,7 @@ private fun KnockRequestAvatarView(
@Composable
private fun KnockRequestAvatarListView(
knockRequests: ImmutableList<KnockRequest>,
knockRequests: ImmutableList<KnockRequestPresentable>,
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),
)
}

View file

@ -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
}

View file

@ -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<KnockRequestPermissions> {
return syncUpdateFlow.map {
val canAccept = canInvite().getOrDefault(false)
val canDecline = canKick().getOrDefault(false)
val canBan = canBan().getOrDefault(false)
KnockRequestPermissions(canAccept, canDecline, canBan)
}
}

View file

@ -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
}
}

View file

@ -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<Unit> = inner.accept()
suspend fun decline(reason: String?): Result<Unit> = inner.decline(reason)
suspend fun declineAndBan(reason: String?): Result<Unit> = inner.declineAndBan(reason)
suspend fun markAsSeen(): Result<Unit> = inner.markAsSeen()
}

View file

@ -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()
}

View file

@ -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
)
}
}

View file

@ -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<List<KnockRequest>>,
permissionsFlow: Flow<KnockRequestPermissions>,
isKnockFeatureEnabledFlow: Flow<Boolean>,
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<Set<EventId>>(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<Unit> {
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<Unit> {
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<Unit> {
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<Unit> = 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<Unit>)
): Result<Unit> {
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<Unit>(KnockRequestsException.KnockRequestNotFound)

View file

@ -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
}

View file

@ -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<KnockRequestsListState> {
@Composable
override fun present(): KnockRequestsListState {
val currentAction = remember { mutableStateOf<KnockRequestsCurrentAction>(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<Unit>>(AsyncAction.Uninitialized) }
var currentAction by remember { mutableStateOf<KnockRequestsAction>(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<AsyncAction<Unit>>,
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
}
}
}

View file

@ -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<ImmutableList<KnockRequest>>,
val currentAction: KnockRequestsCurrentAction,
val canAccept: Boolean,
val canDecline: Boolean,
val canBan: Boolean,
val knockRequests: AsyncData<ImmutableList<KnockRequestPresentable>>,
val currentAction: KnockRequestsAction,
val asyncAction: AsyncAction<Unit>,
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<Unit>) : KnockRequestsCurrentAction
data class Decline(val knockRequest: KnockRequest, val async: AsyncAction<Unit>) : KnockRequestsCurrentAction
data class DeclineAndBan(val knockRequest: KnockRequest, val async: AsyncAction<Unit>) : KnockRequestsCurrentAction
data class AcceptAll(val async: AsyncAction<Unit>) : 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
}

View file

@ -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<KnockReques
aKnockRequestsListState(
knockRequests = AsyncData.Success(
persistentListOf(
aKnockRequest()
aKnockRequestPresentable()
)
),
),
aKnockRequestsListState(
knockRequests = AsyncData.Success(
persistentListOf(
aKnockRequest(
aKnockRequestPresentable(
reason = "A very long reason that should probably be truncated, " +
"but could be also expanded so you can see it over the lines, wow," +
"very amazing reason, I know, right, I'm so good at writing reasons."
@ -48,8 +49,8 @@ open class KnockRequestsListStateProvider : PreviewParameterProvider<KnockReques
aKnockRequestsListState(
knockRequests = AsyncData.Success(
persistentListOf(
aKnockRequest(),
aKnockRequest(
aKnockRequestPresentable(),
aKnockRequestPresentable(
userId = UserId("@user:example.com"),
displayName = null,
avatarUrl = null,
@ -61,59 +62,88 @@ open class KnockRequestsListStateProvider : PreviewParameterProvider<KnockReques
aKnockRequestsListState(
knockRequests = AsyncData.Success(
persistentListOf(
aKnockRequest()
aKnockRequestPresentable()
)
),
currentAction = KnockRequestsCurrentAction.AcceptAll(AsyncAction.Loading),
currentAction = KnockRequestsAction.AcceptAll,
asyncAction = AsyncAction.ConfirmingNoParams,
),
aKnockRequestsListState(
knockRequests = AsyncData.Success(
persistentListOf(
aKnockRequest()
aKnockRequestPresentable()
)
),
canAccept = false,
currentAction = KnockRequestsAction.AcceptAll,
asyncAction = AsyncAction.Loading,
),
aKnockRequestsListState(
knockRequests = AsyncData.Success(
persistentListOf(
aKnockRequest()
aKnockRequestPresentable()
)
),
canDecline = false,
permissions = KnockRequestPermissions(
canAccept = false,
canDecline = true,
canBan = true,
),
currentAction = KnockRequestsAction.AcceptAll,
asyncAction = AsyncAction.Failure(Throwable("Failed to accept all")),
),
aKnockRequestsListState(
knockRequests = AsyncData.Success(
persistentListOf(
aKnockRequest()
aKnockRequestPresentable()
)
),
canAccept = false,
canDecline = false,
permissions = KnockRequestPermissions(
canAccept = true,
canDecline = false,
canBan = true,
),
),
aKnockRequestsListState(
knockRequests = AsyncData.Success(
persistentListOf(
aKnockRequest()
aKnockRequestPresentable()
)
),
canBan = false,
permissions = KnockRequestPermissions(
canAccept = false,
canDecline = false,
canBan = true,
),
),
aKnockRequestsListState(
knockRequests = AsyncData.Success(
persistentListOf(
aKnockRequestPresentable()
)
),
permissions = KnockRequestPermissions(
canAccept = true,
canDecline = true,
canBan = false,
),
),
)
}
fun aKnockRequestsListState(
knockRequests: AsyncData<ImmutableList<KnockRequest>> = AsyncData.Success(persistentListOf()),
currentAction: KnockRequestsCurrentAction = KnockRequestsCurrentAction.None,
canAccept: Boolean = true,
canDecline: Boolean = true,
canBan: Boolean = true,
knockRequests: AsyncData<ImmutableList<KnockRequestPresentable>> = AsyncData.Success(persistentListOf()),
currentAction: KnockRequestsAction = KnockRequestsAction.None,
asyncAction: AsyncAction<Unit> = 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,
)

View file

@ -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<Unit>,
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<KnockRequest>,
knockRequests: ImmutableList<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,
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,

View file

@ -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<KnockRequest>())
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<Unit>> { 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<Unit>> { 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<Unit>> { 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<List<KnockRequest>> = 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,
)
}

View file

@ -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<ComponentActivity>()
@Test
fun `clicking on view on single request invoke the expected callback`() {
val eventsRecorder = EventsRecorder<KnockRequestsBannerEvents>(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<KnockRequestsBannerEvents>(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<KnockRequestsBannerEvents>()
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<KnockRequestsBannerEvents>()
rule.setKnockRequestsBannerView(
state = aKnockRequestsBannerState(
eventSink = eventsRecorder,
),
)
val close = rule.activity.getString(CommonStrings.action_close)
rule.onNodeWithContentDescription(close).performClick()
eventsRecorder.assertSingle(KnockRequestsBannerEvents.Dismiss)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setKnockRequestsBannerView(
state: KnockRequestsBannerState,
onViewRequestsClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
KnockRequestsBannerView(
state = state,
onViewRequestsClick = onViewRequestsClick,
)
}
}

View file

@ -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<Unit>> { 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<Unit>> { 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<String?, Result<Unit>> { 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<String?, Result<Unit>> { 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<Unit>> { 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<Unit>> { Result.success(Unit) }
val acceptFailureLambda = lambdaRecorder<Result<Unit>> { 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<List<KnockRequest>> = flowOf(emptyList())
): KnockRequestsListPresenter {
val knockRequestsService = KnockRequestsService(
knockRequestsFlow = knockRequestsFlow,
coroutineScope = backgroundScope,
isKnockFeatureEnabledFlow = flowOf(true),
permissionsFlow = flowOf(KnockRequestPermissions(canAccept, canDecline, canBan)),
)
return KnockRequestsListPresenter(knockRequestsService = knockRequestsService)
}
}

View file

@ -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<ComponentActivity>()
@Test
fun `clicking on back invoke the expected callback`() {
val eventsRecorder = EventsRecorder<KnockRequestsListEvents>(expectEvents = false)
ensureCalledOnce {
rule.setKnockRequestsListView(
aKnockRequestsListState(
eventSink = eventsRecorder,
),
onBackClick = it
)
rule.pressBack()
}
}
@Test
fun `clicking on accept emit the expected event`() {
val eventsRecorder = EventsRecorder<KnockRequestsListEvents>()
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<KnockRequestsListEvents>()
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<KnockRequestsListEvents>()
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<KnockRequestsListEvents>()
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<KnockRequestsListEvents>()
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<KnockRequestsListEvents>()
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<KnockRequestsListEvents>()
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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setKnockRequestsListView(
state: KnockRequestsListState,
onBackClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
KnockRequestsListView(
state = state,
onBackClick = onBackClick,
)
}
}

View file

@ -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<Int?>(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,