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 { android {
namespace = "io.element.android.features.knockrequests.impl" namespace = "io.element.android.features.knockrequests.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
} }
setupAnvil() setupAnvil()
@ -26,11 +31,17 @@ dependencies {
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.featureflag.api)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime) testImplementation(libs.molecule.runtime)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) 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 package io.element.android.features.knockrequests.impl.banner
import androidx.compose.runtime.Composable 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.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
import io.element.android.libraries.architecture.AsyncAction import io.element.android.features.knockrequests.impl.data.KnockRequestsService
import io.element.android.libraries.architecture.Presenter 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 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 @Composable
override fun present(): KnockRequestsBannerState { 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) { fun handleEvents(event: KnockRequestsBannerEvents) {
when (event) { when (event) {
is KnockRequestsBannerEvents.AcceptSingleRequest -> Unit is KnockRequestsBannerEvents.AcceptSingleRequest -> {
appCoroutineScope.acceptSingleKnockRequest(
knockRequests = knockRequests,
displayAcceptError = showAcceptError,
)
}
is KnockRequestsBannerEvents.Dismiss -> { is KnockRequestsBannerEvents.Dismiss -> {
shouldShowBanner = false appCoroutineScope.launch {
knockRequestsService.markAllKnockRequestsAsSeen()
}
} }
} }
} }
return KnockRequestsBannerState( return KnockRequestsBannerState(
knockRequests = persistentListOf(), knockRequests = knockRequests,
acceptAction = AsyncAction.Uninitialized, displayAcceptError = showAcceptError.value,
canAccept = false, canAccept = permissions.canAccept,
isVisible = shouldShowBanner, isVisible = shouldShowBanner,
eventSink = ::handleEvents, 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.runtime.Composable
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource 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.R
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.core.extensions.firstIfSingle import io.element.android.libraries.core.extensions.firstIfSingle
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class KnockRequestsBannerState( data class KnockRequestsBannerState(
val isVisible: Boolean, val isVisible: Boolean,
val knockRequests: ImmutableList<KnockRequest>, val knockRequests: ImmutableList<KnockRequestPresentable>,
val acceptAction: AsyncAction<Unit>, val displayAcceptError: Boolean,
val canAccept: Boolean, val canAccept: Boolean,
val eventSink: (KnockRequestsBannerEvents) -> Unit, val eventSink: (KnockRequestsBannerEvents) -> Unit,
) { ) {

View file

@ -8,9 +8,8 @@
package io.element.android.features.knockrequests.impl.banner package io.element.android.features.knockrequests.impl.banner
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.knockrequests.impl.KnockRequest import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
import io.element.android.features.knockrequests.impl.aKnockRequest import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable
import io.element.android.libraries.architecture.AsyncAction
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
class KnockRequestsBannerStateProvider : PreviewParameterProvider<KnockRequestsBannerState> { class KnockRequestsBannerStateProvider : PreviewParameterProvider<KnockRequestsBannerState> {
@ -19,7 +18,7 @@ class KnockRequestsBannerStateProvider : PreviewParameterProvider<KnockRequestsB
aKnockRequestsBannerState(), aKnockRequestsBannerState(),
aKnockRequestsBannerState( aKnockRequestsBannerState(
knockRequests = listOf( knockRequests = listOf(
aKnockRequest( aKnockRequestPresentable(
reason = "A very long reason that should probably be truncated, " + reason = "A very long reason that should probably be truncated, " +
"but could be also expanded so you can see it over the lines, wow," + "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." "very amazing reason, I know, right, I'm so good at writing reasons."
@ -28,30 +27,27 @@ class KnockRequestsBannerStateProvider : PreviewParameterProvider<KnockRequestsB
), ),
aKnockRequestsBannerState( aKnockRequestsBannerState(
knockRequests = listOf( knockRequests = listOf(
aKnockRequest(), aKnockRequestPresentable(),
aKnockRequest(displayName = "Alice") aKnockRequestPresentable(displayName = "Alice")
) )
), ),
aKnockRequestsBannerState( aKnockRequestsBannerState(
knockRequests = listOf( knockRequests = listOf(
aKnockRequest(), aKnockRequestPresentable(),
aKnockRequest(displayName = "Alice"), aKnockRequestPresentable(displayName = "Alice"),
aKnockRequest(displayName = "Bob"), aKnockRequestPresentable(displayName = "Bob"),
aKnockRequest(displayName = "Charlie") aKnockRequestPresentable(displayName = "Charlie")
) )
), ),
aKnockRequestsBannerState( aKnockRequestsBannerState(
canAccept = false canAccept = false
), ),
aKnockRequestsBannerState( aKnockRequestsBannerState(
acceptAction = AsyncAction.Loading displayAcceptError = true
),
aKnockRequestsBannerState(
acceptAction = AsyncAction.Failure(Throwable("Failed to accept knock"))
), ),
aKnockRequestsBannerState( aKnockRequestsBannerState(
knockRequests = listOf( knockRequests = listOf(
aKnockRequest( aKnockRequestPresentable(
displayName = "A_very_long_display_name_so_that_the_text_can_be_displayed_on_multiple_lines" 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( fun aKnockRequestsBannerState(
knockRequests: List<KnockRequest> = listOf(aKnockRequest()), knockRequests: List<KnockRequestPresentable> = listOf(aKnockRequestPresentable()),
acceptAction: AsyncAction<Unit> = AsyncAction.Uninitialized, displayAcceptError: Boolean = false,
canAccept: Boolean = true, canAccept: Boolean = true,
isVisible: Boolean = true, isVisible: Boolean = true,
eventSink: (KnockRequestsBannerEvents) -> Unit = {} eventSink: (KnockRequestsBannerEvents) -> Unit = {}
) = KnockRequestsBannerState( ) = KnockRequestsBannerState(
knockRequests = knockRequests.toImmutableList(), knockRequests = knockRequests.toImmutableList(),
acceptAction = acceptAction, displayAcceptError = displayAcceptError,
canAccept = canAccept, canAccept = canAccept,
isVisible = isVisible, isVisible = isVisible,
eventSink = eventSink, 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
@ -37,9 +39,11 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons 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.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.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@ -61,22 +65,42 @@ fun KnockRequestsBannerView(
onViewRequestsClick: () -> Unit, onViewRequestsClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
AnimatedVisibility( Box(modifier = modifier) {
visible = state.isVisible, AnimatedVisibility(
enter = expandVertically(), visible = state.isVisible,
exit = shrinkVertically(), enter = expandVertically(),
modifier = modifier, exit = shrinkVertically(),
) {
Surface(
shape = MaterialTheme.shapes.small,
color = ElementTheme.colors.bgCanvasDefaultLevel1,
shadowElevation = 24.dp,
modifier = Modifier.padding(16.dp),
) { ) {
KnockRequestsBannerContent( Surface(
state = state, shape = MaterialTheme.shapes.small,
onViewRequestsClick = onViewRequestsClick, 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( Column(
modifier modifier
.fillMaxWidth() .fillMaxWidth()
.padding(all = 16.dp) .padding(all = 16.dp)
) { ) {
Row { Row {
KnockRequestAvatarView( KnockRequestAvatarView(
@ -122,13 +146,15 @@ private fun KnockRequestsBannerContent(
) )
} }
} }
Spacer(modifier = Modifier.width(4.dp))
Icon( Icon(
modifier = Modifier.clickable(onClick = ::onDismissClick), modifier = Modifier.clickable(onClick = ::onDismissClick),
imageVector = CompoundIcons.Close(), imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_close) contentDescription = stringResource(CommonStrings.action_close)
) )
} }
if (state.reason != null) { val reason = state.reason
if (!reason.isNullOrEmpty()) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = state.reason, text = state.reason,
@ -169,7 +195,7 @@ private fun KnockRequestsBannerContent(
@Composable @Composable
private fun KnockRequestAvatarView( private fun KnockRequestAvatarView(
knockRequests: ImmutableList<KnockRequest>, knockRequests: ImmutableList<KnockRequestPresentable>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Box(modifier) { Box(modifier) {
@ -183,7 +209,7 @@ private fun KnockRequestAvatarView(
@Composable @Composable
private fun KnockRequestAvatarListView( private fun KnockRequestAvatarListView(
knockRequests: ImmutableList<KnockRequest>, knockRequests: ImmutableList<KnockRequestPresentable>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val avatarSize = AvatarSize.KnockRequestBanner.dp val avatarSize = AvatarSize.KnockRequestBanner.dp
@ -198,27 +224,27 @@ private fun KnockRequestAvatarListView(
smallReversedList.forEachIndexed { index, knockRequest -> smallReversedList.forEachIndexed { index, knockRequest ->
Avatar( Avatar(
modifier = Modifier modifier = Modifier
.padding(start = avatarSize / 2 * (lastItemIndex - index)) .padding(start = avatarSize / 2 * (lastItemIndex - index))
.graphicsLayer { .graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen 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,
)
} }
} .drawWithContent {
.size(size = avatarSize) // Draw content and clear the pixels for the avatar on the left.
.padding(2.dp), 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), 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 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 { sealed interface KnockRequestsListEvents {
data class Accept(val knockRequest: KnockRequest) : KnockRequestsListEvents data class Accept(val knockRequest: KnockRequestPresentable) : KnockRequestsListEvents
data class Decline(val knockRequest: KnockRequest) : KnockRequestsListEvents data class Decline(val knockRequest: KnockRequestPresentable) : KnockRequestsListEvents
data class DeclineAndBan(val knockRequest: KnockRequest) : KnockRequestsListEvents data class DeclineAndBan(val knockRequest: KnockRequestPresentable) : KnockRequestsListEvents
data object AcceptAll : 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.matrix.ui.room.canBanAsState import kotlinx.coroutines.CoroutineScope
import io.element.android.libraries.matrix.ui.room.canInviteAsState import kotlinx.coroutines.launch
import io.element.android.libraries.matrix.ui.room.canKickAsState
import kotlinx.collections.immutable.persistentListOf
import javax.inject.Inject import javax.inject.Inject
class KnockRequestsListPresenter @Inject constructor( class KnockRequestsListPresenter @Inject constructor(
private val room: MatrixRoom, private val knockRequestsService: KnockRequestsService,
) : Presenter<KnockRequestsListState> { ) : Presenter<KnockRequestsListState> {
@Composable @Composable
override fun present(): KnockRequestsListState { override fun present(): KnockRequestsListState {
val currentAction = remember { mutableStateOf<KnockRequestsCurrentAction>(KnockRequestsCurrentAction.None) } val asyncAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
val syncUpdateFlow = room.syncUpdateFlow.collectAsState() var currentAction by remember { mutableStateOf<KnockRequestsAction>(KnockRequestsAction.None) }
val canBan by room.canBanAsState(syncUpdateFlow.value)
val canDecline by room.canKickAsState(syncUpdateFlow.value) val permissions by knockRequestsService.permissionsFlow.collectAsState()
val canAccept by room.canInviteAsState(syncUpdateFlow.value) val knockRequests by knockRequestsService.knockRequestsFlow.collectAsState()
val coroutineScope = rememberCoroutineScope()
fun handleEvents(event: KnockRequestsListEvents) { fun handleEvents(event: KnockRequestsListEvents) {
when (event) { when (event) {
KnockRequestsListEvents.AcceptAll -> { KnockRequestsListEvents.AcceptAll -> {
currentAction.value = KnockRequestsCurrentAction.AcceptAll(AsyncAction.Uninitialized) currentAction = KnockRequestsAction.AcceptAll
} }
is KnockRequestsListEvents.Accept -> { is KnockRequestsListEvents.Accept -> {
currentAction.value = KnockRequestsCurrentAction.Accept(event.knockRequest, AsyncAction.Uninitialized) currentAction = KnockRequestsAction.Accept(event.knockRequest)
} }
is KnockRequestsListEvents.Decline -> { is KnockRequestsListEvents.Decline -> {
currentAction.value = KnockRequestsCurrentAction.Decline(event.knockRequest, AsyncAction.Uninitialized) currentAction = KnockRequestsAction.Decline(event.knockRequest)
} }
is KnockRequestsListEvents.DeclineAndBan -> { is KnockRequestsListEvents.DeclineAndBan -> {
currentAction.value = KnockRequestsCurrentAction.DeclineAndBan(event.knockRequest, AsyncAction.Uninitialized) currentAction = KnockRequestsAction.DeclineAndBan(event.knockRequest)
} }
KnockRequestsListEvents.DismissCurrentAction -> { KnockRequestsListEvents.ResetCurrentAction -> {
currentAction.value = KnockRequestsCurrentAction.None 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) { LaunchedEffect(currentAction) {
when (currentAction.value) { executeAction(currentAction, asyncAction, isActionConfirmed = false)
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
}
} }
return KnockRequestsListState( return KnockRequestsListState(
knockRequests = AsyncData.Success(persistentListOf()), knockRequests = knockRequests,
currentAction = currentAction.value, currentAction = currentAction,
canAccept = canAccept, permissions = permissions,
canDecline = canDecline, asyncAction = asyncAction.value,
canBan = canBan,
eventSink = ::handleEvents 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 package io.element.android.features.knockrequests.impl.list
import androidx.compose.runtime.Immutable 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.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class KnockRequestsListState( data class KnockRequestsListState(
val knockRequests: AsyncData<ImmutableList<KnockRequest>>, val knockRequests: AsyncData<ImmutableList<KnockRequestPresentable>>,
val currentAction: KnockRequestsCurrentAction, val currentAction: KnockRequestsAction,
val canAccept: Boolean, val asyncAction: AsyncAction<Unit>,
val canDecline: Boolean, val permissions: KnockRequestPermissions,
val canBan: Boolean,
val eventSink: (KnockRequestsListEvents) -> Unit, 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 @Immutable
sealed interface KnockRequestsCurrentAction { sealed interface KnockRequestsAction {
data object None : KnockRequestsCurrentAction data object None : KnockRequestsAction
data class Accept(val knockRequest: KnockRequest, val async: AsyncAction<Unit>) : KnockRequestsCurrentAction data class Accept(val knockRequest: KnockRequestPresentable) : KnockRequestsAction
data class Decline(val knockRequest: KnockRequest, val async: AsyncAction<Unit>) : KnockRequestsCurrentAction data class Decline(val knockRequest: KnockRequestPresentable) : KnockRequestsAction
data class DeclineAndBan(val knockRequest: KnockRequest, val async: AsyncAction<Unit>) : KnockRequestsCurrentAction data class DeclineAndBan(val knockRequest: KnockRequestPresentable) : KnockRequestsAction
data class AcceptAll(val async: AsyncAction<Unit>) : KnockRequestsCurrentAction data object AcceptAll : KnockRequestsAction
} }

View file

@ -8,8 +8,9 @@
package io.element.android.features.knockrequests.impl.list package io.element.android.features.knockrequests.impl.list
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.knockrequests.impl.KnockRequest import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions
import io.element.android.features.knockrequests.impl.aKnockRequest 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.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@ -30,14 +31,14 @@ open class KnockRequestsListStateProvider : PreviewParameterProvider<KnockReques
aKnockRequestsListState( aKnockRequestsListState(
knockRequests = AsyncData.Success( knockRequests = AsyncData.Success(
persistentListOf( persistentListOf(
aKnockRequest() aKnockRequestPresentable()
) )
), ),
), ),
aKnockRequestsListState( aKnockRequestsListState(
knockRequests = AsyncData.Success( knockRequests = AsyncData.Success(
persistentListOf( persistentListOf(
aKnockRequest( aKnockRequestPresentable(
reason = "A very long reason that should probably be truncated, " + reason = "A very long reason that should probably be truncated, " +
"but could be also expanded so you can see it over the lines, wow," + "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." "very amazing reason, I know, right, I'm so good at writing reasons."
@ -48,8 +49,8 @@ open class KnockRequestsListStateProvider : PreviewParameterProvider<KnockReques
aKnockRequestsListState( aKnockRequestsListState(
knockRequests = AsyncData.Success( knockRequests = AsyncData.Success(
persistentListOf( persistentListOf(
aKnockRequest(), aKnockRequestPresentable(),
aKnockRequest( aKnockRequestPresentable(
userId = UserId("@user:example.com"), userId = UserId("@user:example.com"),
displayName = null, displayName = null,
avatarUrl = null, avatarUrl = null,
@ -61,59 +62,88 @@ open class KnockRequestsListStateProvider : PreviewParameterProvider<KnockReques
aKnockRequestsListState( aKnockRequestsListState(
knockRequests = AsyncData.Success( knockRequests = AsyncData.Success(
persistentListOf( persistentListOf(
aKnockRequest() aKnockRequestPresentable()
) )
), ),
currentAction = KnockRequestsCurrentAction.AcceptAll(AsyncAction.Loading), currentAction = KnockRequestsAction.AcceptAll,
asyncAction = AsyncAction.ConfirmingNoParams,
), ),
aKnockRequestsListState( aKnockRequestsListState(
knockRequests = AsyncData.Success( knockRequests = AsyncData.Success(
persistentListOf( persistentListOf(
aKnockRequest() aKnockRequestPresentable()
) )
), ),
canAccept = false, currentAction = KnockRequestsAction.AcceptAll,
asyncAction = AsyncAction.Loading,
), ),
aKnockRequestsListState( aKnockRequestsListState(
knockRequests = AsyncData.Success( knockRequests = AsyncData.Success(
persistentListOf( 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( aKnockRequestsListState(
knockRequests = AsyncData.Success( knockRequests = AsyncData.Success(
persistentListOf( persistentListOf(
aKnockRequest() aKnockRequestPresentable()
) )
), ),
canAccept = false, permissions = KnockRequestPermissions(
canDecline = false, canAccept = true,
canDecline = false,
canBan = true,
),
), ),
aKnockRequestsListState( aKnockRequestsListState(
knockRequests = AsyncData.Success( knockRequests = AsyncData.Success(
persistentListOf( 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( fun aKnockRequestsListState(
knockRequests: AsyncData<ImmutableList<KnockRequest>> = AsyncData.Success(persistentListOf()), knockRequests: AsyncData<ImmutableList<KnockRequestPresentable>> = AsyncData.Success(persistentListOf()),
currentAction: KnockRequestsCurrentAction = KnockRequestsCurrentAction.None, currentAction: KnockRequestsAction = KnockRequestsAction.None,
canAccept: Boolean = true, asyncAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
canDecline: Boolean = true, permissions: KnockRequestPermissions = KnockRequestPermissions(
canBan: Boolean = true, canAccept = true,
canDecline = true,
canBan = true,
),
eventSink: (KnockRequestsListEvents) -> Unit = {}, eventSink: (KnockRequestsListEvents) -> Unit = {},
) = KnockRequestsListState( ) = KnockRequestsListState(
knockRequests = knockRequests, knockRequests = knockRequests,
currentAction = currentAction, currentAction = currentAction,
canAccept = canAccept, asyncAction = asyncAction,
canDecline = canDecline, permissions = permissions,
canBan = canBan,
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -11,6 +11,7 @@ import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@ -46,23 +47,25 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons 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.R
import io.element.android.features.knockrequests.impl.getAvatarData import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
import io.element.android.features.knockrequests.impl.getBestName import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.BigIcon 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.async.AsyncActionView
import io.element.android.libraries.designsystem.components.avatar.Avatar 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.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.aliasScreenTitle 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.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize 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.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.OutlinedButton import io.element.android.libraries.designsystem.theme.components.OutlinedButton
@ -100,14 +103,18 @@ private fun KnockRequestsListContent(
state: KnockRequestsListState, state: KnockRequestsListState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun onAcceptClick(knockRequest: KnockRequest) { fun onAcceptClick(knockRequest: KnockRequestPresentable) {
state.eventSink(KnockRequestsListEvents.Accept(knockRequest)) state.eventSink(KnockRequestsListEvents.Accept(knockRequest))
} }
fun onDeclineClick(knockRequest: KnockRequest) { fun onDeclineClick(knockRequest: KnockRequestPresentable) {
state.eventSink(KnockRequestsListEvents.Decline(knockRequest)) state.eventSink(KnockRequestsListEvents.Decline(knockRequest))
} }
fun onBanClick(knockRequest: KnockRequestPresentable) {
state.eventSink(KnockRequestsListEvents.DeclineAndBan(knockRequest))
}
var bottomPaddingInPixels by remember { mutableIntStateOf(0) } var bottomPaddingInPixels by remember { mutableIntStateOf(0) }
Box(modifier.fillMaxSize()) { Box(modifier.fillMaxSize()) {
@ -119,21 +126,43 @@ private fun KnockRequestsListContent(
} else { } else {
KnockRequestsList( KnockRequestsList(
knockRequests = knockRequests, knockRequests = knockRequests,
canAccept = state.canAccept, canAccept = state.permissions.canAccept,
canDecline = state.canDecline, canDecline = state.permissions.canDecline,
canBan = state.canBan, canBan = state.permissions.canBan,
onAcceptClick = ::onAcceptClick, onAcceptClick = ::onAcceptClick,
onDeclineClick = ::onDeclineClick, onDeclineClick = ::onDeclineClick,
onBanClick = ::onBanClick,
contentPadding = PaddingValues(bottom = bottomPaddingInPixels.toDp()), 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 else -> Unit
} }
KnockRequestsActionsView( KnockRequestsActionsView(
actions = state.currentAction, currentAction = state.currentAction,
asyncAction = state.asyncAction,
onConfirm = {
state.eventSink(KnockRequestsListEvents.ConfirmCurrentAction)
},
onRetry = {
state.eventSink(KnockRequestsListEvents.RetryCurrentAction)
},
onDismiss = { onDismiss = {
state.eventSink(KnockRequestsListEvents.DismissCurrentAction) state.eventSink(KnockRequestsListEvents.ResetCurrentAction)
}, },
) )
if (state.canAcceptAll) { if (state.canAcceptAll) {
@ -152,53 +181,104 @@ private fun KnockRequestsListContent(
@Composable @Composable
private fun KnockRequestsActionsView( private fun KnockRequestsActionsView(
actions: KnockRequestsCurrentAction, currentAction: KnockRequestsAction,
asyncAction: AsyncAction<Unit>,
onConfirm: () -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onRetry: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Box(modifier) { Box(modifier) {
when (actions) { AsyncActionView(
is KnockRequestsCurrentAction.AcceptAll -> { async = asyncAction,
AsyncActionView( onSuccess = { onDismiss() },
async = actions.async, onErrorDismiss = onDismiss,
onSuccess = {}, confirmationDialog = {
onErrorDismiss = onDismiss, KnockRequestActionConfirmation(
currentAction = currentAction,
onSubmit = onConfirm,
onDismiss = onDismiss,
) )
} },
is KnockRequestsCurrentAction.Accept -> { progressDialog = {
AsyncActionView( KnockRequestActionProgress(target = currentAction)
async = actions.async, },
onSuccess = {}, errorMessage = {
onErrorDismiss = onDismiss, 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 KnockRequestsCurrentAction.Decline -> { is KnockRequestsAction.DeclineAndBan -> stringResource(R.string.screen_knock_requests_list_decline_failed_alert_description)
AsyncActionView( KnockRequestsAction.AcceptAll -> stringResource(R.string.screen_knock_requests_list_accept_all_failed_alert_description)
async = actions.async, else -> ""
onSuccess = {}, }
onErrorDismiss = onDismiss, },
) onRetry = onRetry,
} )
is KnockRequestsCurrentAction.DeclineAndBan -> {
AsyncActionView(
async = actions.async,
onSuccess = {},
onErrorDismiss = onDismiss,
)
}
KnockRequestsCurrentAction.None -> Unit
}
} }
} }
@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 @Composable
private fun KnockRequestsList( private fun KnockRequestsList(
knockRequests: ImmutableList<KnockRequest>, knockRequests: ImmutableList<KnockRequestPresentable>,
canAccept: Boolean, canAccept: Boolean,
canDecline: Boolean, canDecline: Boolean,
canBan: Boolean, canBan: Boolean,
onAcceptClick: (KnockRequest) -> Unit, onAcceptClick: (KnockRequestPresentable) -> Unit,
onDeclineClick: (KnockRequest) -> Unit, onDeclineClick: (KnockRequestPresentable) -> Unit,
onBanClick: (KnockRequestPresentable) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp), contentPadding: PaddingValues = PaddingValues(0.dp),
) { ) {
@ -214,6 +294,7 @@ private fun KnockRequestsList(
canDecline = canDecline, canDecline = canDecline,
canAccept = canAccept, canAccept = canAccept,
onDeclineClick = onDeclineClick, onDeclineClick = onDeclineClick,
onBanClick = onBanClick,
) )
if (index != knockRequests.size - 1) { if (index != knockRequests.size - 1) {
HorizontalDivider() HorizontalDivider()
@ -224,12 +305,13 @@ private fun KnockRequestsList(
@Composable @Composable
private fun KnockRequestItem( private fun KnockRequestItem(
knockRequest: KnockRequest, knockRequest: KnockRequestPresentable,
canAccept: Boolean, canAccept: Boolean,
canDecline: Boolean, canDecline: Boolean,
canBan: Boolean, canBan: Boolean,
onAcceptClick: (KnockRequest) -> Unit, onAcceptClick: (KnockRequestPresentable) -> Unit,
onDeclineClick: (KnockRequest) -> Unit, onDeclineClick: (KnockRequestPresentable) -> Unit,
onBanClick: (KnockRequestPresentable) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Row(
@ -252,10 +334,11 @@ private fun KnockRequestItem(
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
style = ElementTheme.typography.fontBodyLgMedium, style = ElementTheme.typography.fontBodyLgMedium,
) )
if (!knockRequest.formattedDate.isNullOrEmpty()) { val formattedDate = knockRequest.formattedDate
if (!formattedDate.isNullOrEmpty()) {
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = knockRequest.formattedDate, text = formattedDate,
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
style = ElementTheme.typography.fontBodySmRegular, style = ElementTheme.typography.fontBodySmRegular,
) )
@ -272,7 +355,8 @@ private fun KnockRequestItem(
) )
} }
// Reason // Reason
if (!knockRequest.reason.isNullOrBlank()) { val reason = knockRequest.reason
if (!reason.isNullOrBlank()) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
var isExpanded by rememberSaveable(knockRequest.userId) { mutableStateOf(false) } var isExpanded by rememberSaveable(knockRequest.userId) { mutableStateOf(false) }
var isExpandable 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 } .clickable(enabled = isExpandable) { isExpanded = !isExpanded }
) { ) {
Text( Text(
text = knockRequest.reason, text = reason,
style = ElementTheme.typography.fontBodyMdRegular, style = ElementTheme.typography.fontBodyMdRegular,
maxLines = if (isExpanded) Int.MAX_VALUE else 3, maxLines = if (isExpanded) Int.MAX_VALUE else 3,
onTextLayout = { result -> onTextLayout = { result ->
@ -336,7 +420,7 @@ private fun KnockRequestItem(
TextButton( TextButton(
text = stringResource(R.string.screen_knock_requests_list_decline_and_ban_action_title), text = stringResource(R.string.screen_knock_requests_list_decline_and_ban_action_title),
onClick = { onClick = {
onAcceptClick(knockRequest) onBanClick(knockRequest)
}, },
destructive = true, destructive = true,
size = ButtonSize.Small, 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.RoomMember
import io.element.android.libraries.matrix.api.room.StateEventType 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.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.canInvite
import io.element.android.libraries.matrix.api.room.powerlevels.canSendState import io.element.android.libraries.matrix.api.room.powerlevels.canSendState
import io.element.android.libraries.matrix.api.room.roomNotificationSettings 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 roomName by remember { derivedStateOf { (roomInfo?.name ?: room.displayName).trim() } }
val roomTopic by remember { derivedStateOf { roomInfo?.topic ?: room.topic } } val roomTopic by remember { derivedStateOf { roomInfo?.topic ?: room.topic } }
val isFavorite by remember { derivedStateOf { roomInfo?.isFavorite.orFalse() } } 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() val canShowPinnedMessages = isPinnedMessagesFeatureEnabled()
var canShowMediaGallery by remember { mutableStateOf(false) } var canShowMediaGallery by remember { mutableStateOf(false) }
@ -106,11 +107,8 @@ class RoomDetailsPresenter @Inject constructor(
val roomType by getRoomType(dmMember, currentMember) val roomType by getRoomType(dmMember, currentMember)
val roomCallState = roomCallStatePresenter.present() val roomCallState = roomCallStatePresenter.present()
val canHandleKnockRequests by room.canHandleKnockRequestsAsState(syncUpdateFlow.value)
val topicState = remember(canEditTopic, roomTopic, roomType) { val topicState = remember(canEditTopic, roomTopic, roomType) {
val topic = roomTopic val topic = roomTopic
when { when {
!topic.isNullOrBlank() -> RoomTopicState.ExistingTopic(topic) !topic.isNullOrBlank() -> RoomTopicState.ExistingTopic(topic)
canEditTopic && roomType is RoomDetailsType.Room -> RoomTopicState.CanAddTopic 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 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 { val canShowKnockRequests by remember {
derivedStateOf { isKnockRequestsEnabled && canHandleKnockRequests } derivedStateOf { isKnockRequestsEnabled && canHandleKnockRequests && joinRule == JoinRule.Knock }
} }
val roomNotificationSettingsState by room.roomNotificationSettingsStateFlow.collectAsState() val roomNotificationSettingsState by room.roomNotificationSettingsStateFlow.collectAsState()
@ -164,7 +165,7 @@ class RoomDetailsPresenter @Inject constructor(
roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(), roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(),
isFavorite = isFavorite, isFavorite = isFavorite,
displayRolesAndPermissionsSettings = !room.isDm && isUserAdmin, displayRolesAndPermissionsSettings = !room.isDm && isUserAdmin,
isPublic = isPublic, isPublic = joinRule == JoinRule.Public,
heroes = roomInfo?.heroes.orEmpty().toPersistentList(), heroes = roomInfo?.heroes.orEmpty().toPersistentList(),
canShowPinnedMessages = canShowPinnedMessages, canShowPinnedMessages = canShowPinnedMessages,
canShowMediaGallery = canShowMediaGallery, canShowMediaGallery = canShowMediaGallery,

View file

@ -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.media.VideoInfo
import io.element.android.libraries.matrix.api.poll.PollKind 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.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.location.AssetType
import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange 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.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import java.io.Closeable import java.io.Closeable
@ -52,10 +54,17 @@ interface MatrixRoom : Closeable {
val activeMemberCount: Long val activeMemberCount: Long
val joinedMemberCount: Long val joinedMemberCount: Long
val roomCoroutineScope: CoroutineScope
val roomInfoFlow: Flow<MatrixRoomInfo> val roomInfoFlow: Flow<MatrixRoomInfo>
val roomTypingMembersFlow: Flow<List<UserId>> val roomTypingMembersFlow: Flow<List<UserId>>
val identityStateChangesFlow: Flow<List<IdentityStateChange>> val identityStateChangesFlow: Flow<List<IdentityStateChange>>
/**
* The current knock requests in the room as a Flow.
*/
val knockRequestsFlow: Flow<List<KnockRequest>>
/** /**
* A one-to-one is a room with exactly 2 members. * 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). * See [the Matrix spec](https://spec.matrix.org/latest/client-server-api/#default-underride-rules).

View file

@ -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.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
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.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.ImmutableMap
@ -27,6 +28,7 @@ data class MatrixRoomInfo(
val avatarUrl: String?, val avatarUrl: String?,
val isDirect: Boolean, val isDirect: Boolean,
val isPublic: Boolean, val isPublic: Boolean,
val joinRule: JoinRule?,
val isSpace: Boolean, val isSpace: Boolean,
val isTombstoned: Boolean, val isTombstoned: Boolean,
val isFavorite: Boolean, val isFavorite: Boolean,

View file

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

View file

@ -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<AllowRule>) : JoinRule
data class KnockRestricted(val rules: List<AllowRule>) : JoinRule
data class Custom(val value: String) : JoinRule
}

View file

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

View file

@ -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.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.user.MatrixUser 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 io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -36,6 +37,7 @@ class MatrixRoomInfoMapper {
avatarUrl = it.avatarUrl, avatarUrl = it.avatarUrl,
isDirect = it.isDirect, isDirect = it.isDirect,
isPublic = it.isPublic, isPublic = it.isPublic,
joinRule = it.joinRule?.map(),
isSpace = it.isSpace, isSpace = it.isSpace,
isTombstoned = it.isTombstoned, isTombstoned = it.isTombstoned,
isFavorite = it.isFavourite, isFavorite = it.isFavourite,

View file

@ -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.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.StateEventType 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.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.location.AssetType
import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange 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.core.RustSendHandle
import io.element.android.libraries.matrix.impl.mapper.map 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.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.RoomMemberListFetcher
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
import io.element.android.libraries.matrix.impl.room.powerlevels.RoomPowerLevelsMapper import io.element.android.libraries.matrix.impl.room.powerlevels.RoomPowerLevelsMapper
@ -76,6 +78,7 @@ import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.DateDividerMode import org.matrix.rustcomponents.sdk.DateDividerMode
import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener
import org.matrix.rustcomponents.sdk.KnockRequestsListener
import org.matrix.rustcomponents.sdk.RoomInfo import org.matrix.rustcomponents.sdk.RoomInfo
import org.matrix.rustcomponents.sdk.RoomInfoListener import org.matrix.rustcomponents.sdk.RoomInfoListener
import org.matrix.rustcomponents.sdk.RoomListItem import org.matrix.rustcomponents.sdk.RoomListItem
@ -91,6 +94,7 @@ import uniffi.matrix_sdk.RoomPowerLevelChanges
import java.io.File import java.io.File
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
import org.matrix.rustcomponents.sdk.IdentityStatusChange as RustIdentityStateChange 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.Room as InnerRoom
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
@ -157,13 +161,22 @@ class RustMatrixRoom(
}) })
} }
override val knockRequestsFlow: Flow<List<KnockRequest>> = mxCallbackFlow {
innerRoom.subscribeToKnockRequests(object : KnockRequestsListener {
override fun call(joinRequests: List<InnerKnockRequest>) {
val knockRequests = joinRequests.map { RustKnockRequest(it) }
channel.trySend(knockRequests)
}
})
}
// Create a dispatcher for all room methods... // Create a dispatcher for all room methods...
private val roomDispatcher = coroutineDispatchers.io.limitedParallelism(32) private val roomDispatcher = coroutineDispatchers.io.limitedParallelism(32)
// ...except getMember methods as it could quickly fill the roomDispatcher... // ...except getMember methods as it could quickly fill the roomDispatcher...
private val roomMembersDispatcher = coroutineDispatchers.io.limitedParallelism(8) 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 _syncUpdateFlow = MutableStateFlow(0L)
private val roomMemberListFetcher = RoomMemberListFetcher(innerRoom, roomMembersDispatcher) private val roomMemberListFetcher = RoomMemberListFetcher(innerRoom, roomMembersDispatcher)

View file

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

View file

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

View file

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

View file

@ -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.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo 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.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.api.user.MatrixUser
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomHero import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomHero
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomInfo 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 kotlinx.collections.immutable.toPersistentList
import org.junit.Test import org.junit.Test
import org.matrix.rustcomponents.sdk.Membership import org.matrix.rustcomponents.sdk.Membership
import org.matrix.rustcomponents.sdk.JoinRule as RustJoinRule
import org.matrix.rustcomponents.sdk.RoomNotificationMode as RustRoomNotificationMode import org.matrix.rustcomponents.sdk.RoomNotificationMode as RustRoomNotificationMode
class MatrixRoomInfoMapperTest { class MatrixRoomInfoMapperTest {
@ -47,6 +49,7 @@ class MatrixRoomInfoMapperTest {
isDirect = true, isDirect = true,
isPublic = false, isPublic = false,
isSpace = false, isSpace = false,
joinRule = RustJoinRule.Invite,
isTombstoned = false, isTombstoned = false,
isFavourite = false, isFavourite = false,
canonicalAlias = A_ROOM_ALIAS.value, canonicalAlias = A_ROOM_ALIAS.value,
@ -83,6 +86,7 @@ class MatrixRoomInfoMapperTest {
isSpace = false, isSpace = false,
isTombstoned = false, isTombstoned = false,
isFavorite = false, isFavorite = false,
joinRule = JoinRule.Invite,
canonicalAlias = A_ROOM_ALIAS, canonicalAlias = A_ROOM_ALIAS,
alternativeAliases = listOf(A_ROOM_ALIAS).toImmutableList(), alternativeAliases = listOf(A_ROOM_ALIAS).toImmutableList(),
currentUserMembership = CurrentUserMembership.JOINED, currentUserMembership = CurrentUserMembership.JOINED,
@ -125,6 +129,7 @@ class MatrixRoomInfoMapperTest {
avatarUrl = null, avatarUrl = null,
isDirect = false, isDirect = false,
isPublic = true, isPublic = true,
joinRule = null,
isSpace = false, isSpace = false,
isTombstoned = false, isTombstoned = false,
isFavourite = true, isFavourite = true,
@ -159,6 +164,7 @@ class MatrixRoomInfoMapperTest {
avatarUrl = null, avatarUrl = null,
isDirect = false, isDirect = false,
isPublic = true, isPublic = true,
joinRule = null,
isSpace = false, isSpace = false,
isTombstoned = false, isTombstoned = false,
isFavorite = true, isFavorite = true,

View file

@ -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.RoomMember
import io.element.android.libraries.matrix.api.room.StateEventType 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.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.location.AssetType
import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange 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.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.test.TestScope
import java.io.File import java.io.File
class FakeMatrixRoom( class FakeMatrixRoom(
@ -72,6 +75,7 @@ class FakeMatrixRoom(
override val activeMemberCount: Long = 234L, override val activeMemberCount: Long = 234L,
val notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(), val notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(),
override val liveTimeline: Timeline = FakeTimeline(), override val liveTimeline: Timeline = FakeTimeline(),
override val roomCoroutineScope: CoroutineScope = TestScope(),
private var roomPermalinkResult: () -> Result<String> = { lambdaError() }, private var roomPermalinkResult: () -> Result<String> = { lambdaError() },
private var eventPermalinkResult: (EventId) -> Result<String> = { lambdaError() }, private var eventPermalinkResult: (EventId) -> Result<String> = { lambdaError() },
private val sendCallNotificationIfNeededResult: () -> Result<Unit> = { lambdaError() }, private val sendCallNotificationIfNeededResult: () -> Result<Unit> = { lambdaError() },
@ -163,6 +167,13 @@ class FakeMatrixRoom(
_identityStateChangesFlow.tryEmit(identityStateChanges) _identityStateChangesFlow.tryEmit(identityStateChanges)
} }
private val _knockRequestsFlow: MutableSharedFlow<List<KnockRequest>> = MutableSharedFlow(replay = 1)
override val knockRequestsFlow: Flow<List<KnockRequest>> = _knockRequestsFlow
fun emitKnockRequests(knockRequests: List<KnockRequest>) {
_knockRequestsFlow.tryEmit(knockRequests)
}
override val membersStateFlow: MutableStateFlow<MatrixRoomMembersState> = MutableStateFlow(MatrixRoomMembersState.Unknown) override val membersStateFlow: MutableStateFlow<MatrixRoomMembersState> = MutableStateFlow(MatrixRoomMembersState.Unknown)
override val roomNotificationSettingsStateFlow: MutableStateFlow<MatrixRoomNotificationSettingsState> = override val roomNotificationSettingsStateFlow: MutableStateFlow<MatrixRoomNotificationSettingsState> =

View file

@ -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.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.RoomMember 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.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.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
@ -33,6 +34,7 @@ fun aRoomInfo(
avatarUrl: String? = AN_AVATAR_URL, avatarUrl: String? = AN_AVATAR_URL,
isDirect: Boolean = false, isDirect: Boolean = false,
isPublic: Boolean = true, isPublic: Boolean = true,
joinRule: JoinRule? = JoinRule.Public,
isSpace: Boolean = false, isSpace: Boolean = false,
isTombstoned: Boolean = false, isTombstoned: Boolean = false,
isFavorite: Boolean = false, isFavorite: Boolean = false,
@ -64,6 +66,7 @@ fun aRoomInfo(
avatarUrl = avatarUrl, avatarUrl = avatarUrl,
isDirect = isDirect, isDirect = isDirect,
isPublic = isPublic, isPublic = isPublic,
joinRule = joinRule,
isSpace = isSpace, isSpace = isSpace,
isTombstoned = isTombstoned, isTombstoned = isTombstoned,
isFavorite = isFavorite, isFavorite = isFavorite,

View file

@ -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.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.RoomMember 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.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.room.message.RoomMessage
import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
@ -46,6 +47,7 @@ fun aRoomSummary(
avatarUrl: String? = null, avatarUrl: String? = null,
isDirect: Boolean = false, isDirect: Boolean = false,
isPublic: Boolean = true, isPublic: Boolean = true,
joinRule: JoinRule? = JoinRule.Public,
isSpace: Boolean = false, isSpace: Boolean = false,
isTombstoned: Boolean = false, isTombstoned: Boolean = false,
isFavorite: Boolean = false, isFavorite: Boolean = false,
@ -79,6 +81,7 @@ fun aRoomSummary(
avatarUrl = avatarUrl, avatarUrl = avatarUrl,
isDirect = isDirect, isDirect = isDirect,
isPublic = isPublic, isPublic = isPublic,
joinRule = joinRule,
isSpace = isSpace, isSpace = isSpace,
isTombstoned = isTombstoned, isTombstoned = isTombstoned,
isFavorite = isFavorite, isFavorite = isFavorite,

View file

@ -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<Unit> = { lambdaError() },
val declineLambda: (String?) -> Result<Unit> = { lambdaError() },
val declineAndBanLambda: (String?) -> Result<Unit> = { lambdaError() },
val markAsSeenLambda: () -> Result<Unit> = { lambdaError() },
) : KnockRequest {
override suspend fun accept(): Result<Unit> = simulateLongTask {
acceptLambda()
}
override suspend fun decline(reason: String?): Result<Unit> = simulateLongTask {
declineLambda(reason)
}
override suspend fun declineAndBan(reason: String?): Result<Unit> = simulateLongTask {
declineAndBanLambda(reason)
}
override suspend fun markAsSeen(): Result<Unit> = simulateLongTask {
markAsSeenLambda()
}
}

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:77db58461ef35ea3d1ab1dda6aff454e376b15d7037e83fbbd965037afa45572 oid sha256:74f93deb90501b746d95e0edf2ae2cef58036a388888e42a9b0fd8aadac9758c
size 29317 size 29447

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:585ea7b1f230ac6a12f8098200b04f433cbb30b39b8be7953dc6e278ffe8179e oid sha256:6a9a248862bd05327e10481f868c6dc10cc1ccad6a56284d497a9fb45f737206
size 34799 size 35042

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:05770e4e11bbc7019ebc113d31eb8b76bbbddc8b1ca6acbb01c0764089047376 oid sha256:ef72766061f056096fc6d030b0e12d05dfa767ea5cac45d71b02cdbd0e78971b
size 17835 size 17859

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a10ccd7db4660bdff998622f5390a33adcaa39472c7d60ebae7c0a5b30a810d4 oid sha256:3b98dc51e1f31bcd64dee223be7beb66b6ddd814189f290876633f5727d09f18
size 27294 size 27383

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:77db58461ef35ea3d1ab1dda6aff454e376b15d7037e83fbbd965037afa45572 oid sha256:a386397f9a0f037050797980a3bd9482ec8a2f1a37d5e150ed8cdd50e5b576a1
size 29317 size 31873

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:77db58461ef35ea3d1ab1dda6aff454e376b15d7037e83fbbd965037afa45572 oid sha256:1b0d57fed7af1716e23f9f1167f70199f5a21f60f3ac97940deccd824754a901
size 29317 size 39156

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:43e80c40ea0b0f6c02d03cf37537fa9e1b8ac71ec5f1d279b51477538a743063
size 39209

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3f2c03edb4eaec4917b95955ac0b51cbdce25ae9710757cad68e3ed863707f7a oid sha256:1a7c6898ea6667e5b7f09ee99ca6b2d75ae3bc20e0db57b9d7a20c48d4681dca
size 27374 size 27308

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3811a02bebe20381a5996bcb61176d258fbb1ca4302cf5f9742470c3f152c780 oid sha256:e229ac5d069412a18ae7deeefd0e88d9897b239ab32a0edd2c2d06f139f1f1af
size 32258 size 32430

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9aaf05a0f912cedf6897fac21e679429b86b0ed1d3e8b6a07cfe7ec6f60cec13 oid sha256:62cf8a40ea8670ce9a23923d5791137ec057b130cde637c4dfb5b0edc75d7971
size 15885 size 15926

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:17d3263b1c47a75083bd597f73baf8cf5faa74274810e85cf2ee086812ca0e60 oid sha256:89fdc1cf49c756e40d81eb858b507f751a6ff661fcb4f3194a5d7c0f738c1b94
size 25248 size 25250

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3f2c03edb4eaec4917b95955ac0b51cbdce25ae9710757cad68e3ed863707f7a oid sha256:ad9532960a251f04ec0fd513df561010a91923ab41de8318f99df23d3799feec
size 27374 size 28232

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3f2c03edb4eaec4917b95955ac0b51cbdce25ae9710757cad68e3ed863707f7a oid sha256:8f2419c455efb74f0f528017e411c16d76755646e3449ae4a62077857e3ea93b
size 27374 size 36848

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cbed125bd5c334bb5d7fce60df615d188c713989bcd6450f59d3f37973ab2bc7
size 36735

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:fbbd687e5e0a1fd3a1be442937e32990dc72a6e4817d6ffc29f6f596eeddef1b oid sha256:0132466eb104c2249958a310b19336f97a5485f98e47a233c29740b14b9907ee
size 8086 size 14298

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9afb58cfcd13c064f0b17d449318d5111aaa914cb8875fe0596c66e5a8a17051
size 30647

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f7e66d3f7d10e759f629795580545cda6644292020ce36763d07e9d476dcc231 oid sha256:dcd89988740d57132796306568378471b67ceb7c182e82fce5fa01dc936a640f
size 30325 size 44821

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:586021e67718aa949b1de04799a43d9da8189fadacdb6dba23405c762fc7ee06 oid sha256:7ee73fabc6f4db8b7fb26b606723c69cf169e3a003a828b74654b6286235e164
size 30618 size 34430

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1b71dc6dc0d29801e81d1d4e52df811624583f69c0510f2f650676747a326455 oid sha256:170e1d045120d172d26d5d7bdd6bbf42c1a5bddcad7ff38d5352cd38c05efed4
size 30296 size 39551

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f6be2cfe7991bce6c622f4f2e45f4ac96810cdc9462d9a4d7aea11baf69ab5ac oid sha256:1b71dc6dc0d29801e81d1d4e52df811624583f69c0510f2f650676747a326455
size 27446 size 30296

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9afb58cfcd13c064f0b17d449318d5111aaa914cb8875fe0596c66e5a8a17051 oid sha256:f6be2cfe7991bce6c622f4f2e45f4ac96810cdc9462d9a4d7aea11baf69ab5ac
size 30647 size 27446

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7ff4bc9e0588ea6a8396572b9ac9fb2401c3193fc3f1fbeb75efd69be6dda24d oid sha256:ab53aa77207ee6984eb5a7a619b5b748178819bd2006ace4282f28f1c6b16cac
size 7867 size 13944

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:68294db4cbd8bb42f4a0b6534b48585341beb2bc405e93cf74a2655f049522ff
size 29768

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c30b6e25ec148d4f98a6c305b7d71af6c7e413cc3425f5a6f5b42a201039f491 oid sha256:00c25643adf7f06ef46e824aa60b35348c18e596a82557b1546b55d29392aa11
size 29007 size 42613

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e5c6abc8bd5ca40eb91d926adc798cee95a8aacb0e87d937983c6240173fe2b9 oid sha256:ad43b5aaa3ea7cf3ab79af88a2a93ba4294527ad2c40c72f4312f3e1b92b4f1a
size 30071 size 33280

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3b67c8d2cedb724ece75a2171463c4e035d562c4480d39b11b1072f4fdae3053 oid sha256:6d9f612c2e6d46ed59ec5a3d1c65fe8953e4fc8f0955bddaa2abef6e1eabb00d
size 29840 size 37194

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:15903c9494100becc4dd2bc6e9fb7f38f8eebbb595cbc251cfc0f839d9a99a27 oid sha256:3b67c8d2cedb724ece75a2171463c4e035d562c4480d39b11b1072f4fdae3053
size 27322 size 29840

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:68294db4cbd8bb42f4a0b6534b48585341beb2bc405e93cf74a2655f049522ff oid sha256:15903c9494100becc4dd2bc6e9fb7f38f8eebbb595cbc251cfc0f839d9a99a27
size 29768 size 27322