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:
commit
7b1f458033
66 changed files with 1878 additions and 317 deletions
|
|
@ -14,6 +14,11 @@ plugins {
|
|||
|
||||
android {
|
||||
namespace = "io.element.android.features.knockrequests.impl"
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setupAnvil()
|
||||
|
|
@ -26,11 +31,17 @@ dependencies {
|
|||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.featureflag.api)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.robolectric)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||
testImplementation(projects.libraries.featureflag.test)
|
||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -8,35 +8,85 @@
|
|||
package io.element.android.features.knockrequests.impl.banner
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
|
||||
import io.element.android.features.knockrequests.impl.data.KnockRequestsService
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import io.element.android.libraries.core.coroutine.mapState
|
||||
import io.element.android.libraries.core.extensions.firstIfSingle
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class KnockRequestsBannerPresenter @Inject constructor() : Presenter<KnockRequestsBannerState> {
|
||||
private const val ACCEPT_ERROR_DISPLAY_DURATION = 1500L
|
||||
|
||||
class KnockRequestsBannerPresenter @Inject constructor(
|
||||
private val knockRequestsService: KnockRequestsService,
|
||||
private val appCoroutineScope: CoroutineScope,
|
||||
) : Presenter<KnockRequestsBannerState> {
|
||||
@Composable
|
||||
override fun present(): KnockRequestsBannerState {
|
||||
var shouldShowBanner by remember { mutableStateOf(false) }
|
||||
val knockRequests by remember {
|
||||
knockRequestsService.knockRequestsFlow.mapState { knockRequests ->
|
||||
knockRequests.dataOrNull().orEmpty()
|
||||
.filter { !it.isSeen }
|
||||
.toImmutableList()
|
||||
}
|
||||
}.collectAsState()
|
||||
|
||||
val permissions by knockRequestsService.permissionsFlow.collectAsState()
|
||||
val showAcceptError = remember { mutableStateOf(false) }
|
||||
|
||||
val shouldShowBanner by remember {
|
||||
derivedStateOf {
|
||||
permissions.canHandle && knockRequests.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
fun handleEvents(event: KnockRequestsBannerEvents) {
|
||||
when (event) {
|
||||
is KnockRequestsBannerEvents.AcceptSingleRequest -> Unit
|
||||
is KnockRequestsBannerEvents.AcceptSingleRequest -> {
|
||||
appCoroutineScope.acceptSingleKnockRequest(
|
||||
knockRequests = knockRequests,
|
||||
displayAcceptError = showAcceptError,
|
||||
)
|
||||
}
|
||||
is KnockRequestsBannerEvents.Dismiss -> {
|
||||
shouldShowBanner = false
|
||||
appCoroutineScope.launch {
|
||||
knockRequestsService.markAllKnockRequestsAsSeen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return KnockRequestsBannerState(
|
||||
knockRequests = persistentListOf(),
|
||||
acceptAction = AsyncAction.Uninitialized,
|
||||
canAccept = false,
|
||||
knockRequests = knockRequests,
|
||||
displayAcceptError = showAcceptError.value,
|
||||
canAccept = permissions.canAccept,
|
||||
isVisible = shouldShowBanner,
|
||||
eventSink = ::handleEvents,
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.acceptSingleKnockRequest(
|
||||
knockRequests: List<KnockRequestPresentable>,
|
||||
displayAcceptError: MutableState<Boolean>,
|
||||
) = launch {
|
||||
val knockRequest = knockRequests.firstIfSingle()
|
||||
if (knockRequest != null) {
|
||||
knockRequestsService.acceptKnockRequest(knockRequest, optimistic = true)
|
||||
.onFailure {
|
||||
displayAcceptError.value = true
|
||||
delay(ACCEPT_ERROR_DISPLAY_DURATION)
|
||||
displayAcceptError.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,17 +10,15 @@ package io.element.android.features.knockrequests.impl.banner
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.features.knockrequests.impl.KnockRequest
|
||||
import io.element.android.features.knockrequests.impl.R
|
||||
import io.element.android.features.knockrequests.impl.getBestName
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
|
||||
import io.element.android.libraries.core.extensions.firstIfSingle
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class KnockRequestsBannerState(
|
||||
val isVisible: Boolean,
|
||||
val knockRequests: ImmutableList<KnockRequest>,
|
||||
val acceptAction: AsyncAction<Unit>,
|
||||
val knockRequests: ImmutableList<KnockRequestPresentable>,
|
||||
val displayAcceptError: Boolean,
|
||||
val canAccept: Boolean,
|
||||
val eventSink: (KnockRequestsBannerEvents) -> Unit,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -8,9 +8,8 @@
|
|||
package io.element.android.features.knockrequests.impl.banner
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.knockrequests.impl.KnockRequest
|
||||
import io.element.android.features.knockrequests.impl.aKnockRequest
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
|
||||
import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
class KnockRequestsBannerStateProvider : PreviewParameterProvider<KnockRequestsBannerState> {
|
||||
|
|
@ -19,7 +18,7 @@ class KnockRequestsBannerStateProvider : PreviewParameterProvider<KnockRequestsB
|
|||
aKnockRequestsBannerState(),
|
||||
aKnockRequestsBannerState(
|
||||
knockRequests = listOf(
|
||||
aKnockRequest(
|
||||
aKnockRequestPresentable(
|
||||
reason = "A very long reason that should probably be truncated, " +
|
||||
"but could be also expanded so you can see it over the lines, wow," +
|
||||
"very amazing reason, I know, right, I'm so good at writing reasons."
|
||||
|
|
@ -28,30 +27,27 @@ class KnockRequestsBannerStateProvider : PreviewParameterProvider<KnockRequestsB
|
|||
),
|
||||
aKnockRequestsBannerState(
|
||||
knockRequests = listOf(
|
||||
aKnockRequest(),
|
||||
aKnockRequest(displayName = "Alice")
|
||||
aKnockRequestPresentable(),
|
||||
aKnockRequestPresentable(displayName = "Alice")
|
||||
)
|
||||
),
|
||||
aKnockRequestsBannerState(
|
||||
knockRequests = listOf(
|
||||
aKnockRequest(),
|
||||
aKnockRequest(displayName = "Alice"),
|
||||
aKnockRequest(displayName = "Bob"),
|
||||
aKnockRequest(displayName = "Charlie")
|
||||
aKnockRequestPresentable(),
|
||||
aKnockRequestPresentable(displayName = "Alice"),
|
||||
aKnockRequestPresentable(displayName = "Bob"),
|
||||
aKnockRequestPresentable(displayName = "Charlie")
|
||||
)
|
||||
),
|
||||
aKnockRequestsBannerState(
|
||||
canAccept = false
|
||||
),
|
||||
aKnockRequestsBannerState(
|
||||
acceptAction = AsyncAction.Loading
|
||||
),
|
||||
aKnockRequestsBannerState(
|
||||
acceptAction = AsyncAction.Failure(Throwable("Failed to accept knock"))
|
||||
displayAcceptError = true
|
||||
),
|
||||
aKnockRequestsBannerState(
|
||||
knockRequests = listOf(
|
||||
aKnockRequest(
|
||||
aKnockRequestPresentable(
|
||||
displayName = "A_very_long_display_name_so_that_the_text_can_be_displayed_on_multiple_lines"
|
||||
)
|
||||
)
|
||||
|
|
@ -60,14 +56,14 @@ class KnockRequestsBannerStateProvider : PreviewParameterProvider<KnockRequestsB
|
|||
}
|
||||
|
||||
fun aKnockRequestsBannerState(
|
||||
knockRequests: List<KnockRequest> = listOf(aKnockRequest()),
|
||||
acceptAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
knockRequests: List<KnockRequestPresentable> = listOf(aKnockRequestPresentable()),
|
||||
displayAcceptError: Boolean = false,
|
||||
canAccept: Boolean = true,
|
||||
isVisible: Boolean = true,
|
||||
eventSink: (KnockRequestsBannerEvents) -> Unit = {}
|
||||
) = KnockRequestsBannerState(
|
||||
knockRequests = knockRequests.toImmutableList(),
|
||||
acceptAction = acceptAction,
|
||||
displayAcceptError = displayAcceptError,
|
||||
canAccept = canAccept,
|
||||
isVisible = isVisible,
|
||||
eventSink = eventSink,
|
||||
|
|
|
|||
|
|
@ -20,9 +20,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
|
|
@ -37,9 +39,11 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
|||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.knockrequests.impl.KnockRequest
|
||||
import io.element.android.features.knockrequests.impl.R
|
||||
import io.element.android.features.knockrequests.impl.getAvatarData
|
||||
import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost
|
||||
import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
|
|
@ -61,22 +65,42 @@ fun KnockRequestsBannerView(
|
|||
onViewRequestsClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = state.isVisible,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = ElementTheme.colors.bgCanvasDefaultLevel1,
|
||||
shadowElevation = 24.dp,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
Box(modifier = modifier) {
|
||||
AnimatedVisibility(
|
||||
visible = state.isVisible,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
KnockRequestsBannerContent(
|
||||
state = state,
|
||||
onViewRequestsClick = onViewRequestsClick,
|
||||
)
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = ElementTheme.colors.bgCanvasDefaultLevel1,
|
||||
shadowElevation = 24.dp,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
) {
|
||||
KnockRequestsBannerContent(
|
||||
state = state,
|
||||
onViewRequestsClick = onViewRequestsClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
KnockRequestsAcceptErrorView(displayError = state.displayAcceptError)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KnockRequestsAcceptErrorView(
|
||||
displayError: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val asyncIndicatorState = rememberAsyncIndicatorState()
|
||||
AsyncIndicatorHost(modifier = modifier.statusBarsPadding(), state = asyncIndicatorState)
|
||||
LaunchedEffect(displayError) {
|
||||
if (displayError) {
|
||||
asyncIndicatorState.enqueue {
|
||||
AsyncIndicator.Custom(text = stringResource(CommonStrings.error_unknown))
|
||||
}
|
||||
} else {
|
||||
asyncIndicatorState.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -96,9 +120,9 @@ private fun KnockRequestsBannerContent(
|
|||
}
|
||||
|
||||
Column(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(all = 16.dp)
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(all = 16.dp)
|
||||
) {
|
||||
Row {
|
||||
KnockRequestAvatarView(
|
||||
|
|
@ -122,13 +146,15 @@ private fun KnockRequestsBannerContent(
|
|||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
modifier = Modifier.clickable(onClick = ::onDismissClick),
|
||||
imageVector = CompoundIcons.Close(),
|
||||
contentDescription = stringResource(CommonStrings.action_close)
|
||||
)
|
||||
}
|
||||
if (state.reason != null) {
|
||||
val reason = state.reason
|
||||
if (!reason.isNullOrEmpty()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = state.reason,
|
||||
|
|
@ -169,7 +195,7 @@ private fun KnockRequestsBannerContent(
|
|||
|
||||
@Composable
|
||||
private fun KnockRequestAvatarView(
|
||||
knockRequests: ImmutableList<KnockRequest>,
|
||||
knockRequests: ImmutableList<KnockRequestPresentable>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier) {
|
||||
|
|
@ -183,7 +209,7 @@ private fun KnockRequestAvatarView(
|
|||
|
||||
@Composable
|
||||
private fun KnockRequestAvatarListView(
|
||||
knockRequests: ImmutableList<KnockRequest>,
|
||||
knockRequests: ImmutableList<KnockRequestPresentable>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val avatarSize = AvatarSize.KnockRequestBanner.dp
|
||||
|
|
@ -198,27 +224,27 @@ private fun KnockRequestAvatarListView(
|
|||
smallReversedList.forEachIndexed { index, knockRequest ->
|
||||
Avatar(
|
||||
modifier = Modifier
|
||||
.padding(start = avatarSize / 2 * (lastItemIndex - index))
|
||||
.graphicsLayer {
|
||||
compositingStrategy = CompositingStrategy.Offscreen
|
||||
}
|
||||
.drawWithContent {
|
||||
// Draw content and clear the pixels for the avatar on the left.
|
||||
drawContent()
|
||||
if (index < lastItemIndex) {
|
||||
drawCircle(
|
||||
color = Color.Black,
|
||||
center = Offset(
|
||||
x = 0f,
|
||||
y = size.height / 2,
|
||||
),
|
||||
radius = avatarSize.toPx() / 2,
|
||||
blendMode = BlendMode.Clear,
|
||||
)
|
||||
.padding(start = avatarSize / 2 * (lastItemIndex - index))
|
||||
.graphicsLayer {
|
||||
compositingStrategy = CompositingStrategy.Offscreen
|
||||
}
|
||||
}
|
||||
.size(size = avatarSize)
|
||||
.padding(2.dp),
|
||||
.drawWithContent {
|
||||
// Draw content and clear the pixels for the avatar on the left.
|
||||
drawContent()
|
||||
if (index < lastItemIndex) {
|
||||
drawCircle(
|
||||
color = Color.Black,
|
||||
center = Offset(
|
||||
x = 0f,
|
||||
y = size.height / 2,
|
||||
),
|
||||
radius = avatarSize.toPx() / 2,
|
||||
blendMode = BlendMode.Clear,
|
||||
)
|
||||
}
|
||||
}
|
||||
.size(size = avatarSize)
|
||||
.padding(2.dp),
|
||||
avatarData = knockRequest.getAvatarData(AvatarSize.KnockRequestBanner),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -7,12 +7,14 @@
|
|||
|
||||
package io.element.android.features.knockrequests.impl.list
|
||||
|
||||
import io.element.android.features.knockrequests.impl.KnockRequest
|
||||
import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
|
||||
|
||||
sealed interface KnockRequestsListEvents {
|
||||
data class Accept(val knockRequest: KnockRequest) : KnockRequestsListEvents
|
||||
data class Decline(val knockRequest: KnockRequest) : KnockRequestsListEvents
|
||||
data class DeclineAndBan(val knockRequest: KnockRequest) : KnockRequestsListEvents
|
||||
data class Accept(val knockRequest: KnockRequestPresentable) : KnockRequestsListEvents
|
||||
data class Decline(val knockRequest: KnockRequestPresentable) : KnockRequestsListEvents
|
||||
data class DeclineAndBan(val knockRequest: KnockRequestPresentable) : KnockRequestsListEvents
|
||||
data object AcceptAll : KnockRequestsListEvents
|
||||
data object DismissCurrentAction : KnockRequestsListEvents
|
||||
data object ResetCurrentAction : KnockRequestsListEvents
|
||||
data object RetryCurrentAction : KnockRequestsListEvents
|
||||
data object ConfirmCurrentAction : KnockRequestsListEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,76 +9,112 @@ package io.element.android.features.knockrequests.impl.list
|
|||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.element.android.features.knockrequests.impl.data.KnockRequestsService
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.ui.room.canBanAsState
|
||||
import io.element.android.libraries.matrix.ui.room.canInviteAsState
|
||||
import io.element.android.libraries.matrix.ui.room.canKickAsState
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import io.element.android.libraries.architecture.runUpdatingState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class KnockRequestsListPresenter @Inject constructor(
|
||||
private val room: MatrixRoom,
|
||||
private val knockRequestsService: KnockRequestsService,
|
||||
) : Presenter<KnockRequestsListState> {
|
||||
@Composable
|
||||
override fun present(): KnockRequestsListState {
|
||||
val currentAction = remember { mutableStateOf<KnockRequestsCurrentAction>(KnockRequestsCurrentAction.None) }
|
||||
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
||||
val canBan by room.canBanAsState(syncUpdateFlow.value)
|
||||
val canDecline by room.canKickAsState(syncUpdateFlow.value)
|
||||
val canAccept by room.canInviteAsState(syncUpdateFlow.value)
|
||||
val asyncAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
|
||||
var currentAction by remember { mutableStateOf<KnockRequestsAction>(KnockRequestsAction.None) }
|
||||
|
||||
val permissions by knockRequestsService.permissionsFlow.collectAsState()
|
||||
val knockRequests by knockRequestsService.knockRequestsFlow.collectAsState()
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
fun handleEvents(event: KnockRequestsListEvents) {
|
||||
when (event) {
|
||||
KnockRequestsListEvents.AcceptAll -> {
|
||||
currentAction.value = KnockRequestsCurrentAction.AcceptAll(AsyncAction.Uninitialized)
|
||||
currentAction = KnockRequestsAction.AcceptAll
|
||||
}
|
||||
is KnockRequestsListEvents.Accept -> {
|
||||
currentAction.value = KnockRequestsCurrentAction.Accept(event.knockRequest, AsyncAction.Uninitialized)
|
||||
currentAction = KnockRequestsAction.Accept(event.knockRequest)
|
||||
}
|
||||
is KnockRequestsListEvents.Decline -> {
|
||||
currentAction.value = KnockRequestsCurrentAction.Decline(event.knockRequest, AsyncAction.Uninitialized)
|
||||
currentAction = KnockRequestsAction.Decline(event.knockRequest)
|
||||
}
|
||||
is KnockRequestsListEvents.DeclineAndBan -> {
|
||||
currentAction.value = KnockRequestsCurrentAction.DeclineAndBan(event.knockRequest, AsyncAction.Uninitialized)
|
||||
currentAction = KnockRequestsAction.DeclineAndBan(event.knockRequest)
|
||||
}
|
||||
KnockRequestsListEvents.DismissCurrentAction -> {
|
||||
currentAction.value = KnockRequestsCurrentAction.None
|
||||
KnockRequestsListEvents.ResetCurrentAction -> {
|
||||
asyncAction.value = AsyncAction.Uninitialized
|
||||
currentAction = KnockRequestsAction.None
|
||||
}
|
||||
KnockRequestsListEvents.RetryCurrentAction -> {
|
||||
coroutineScope.executeAction(currentAction, asyncAction, isActionConfirmed = true)
|
||||
}
|
||||
KnockRequestsListEvents.ConfirmCurrentAction -> {
|
||||
coroutineScope.executeAction(currentAction, asyncAction, isActionConfirmed = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(currentAction) {
|
||||
when (currentAction.value) {
|
||||
is KnockRequestsCurrentAction.Accept -> {
|
||||
// Accept the knock request
|
||||
}
|
||||
is KnockRequestsCurrentAction.Decline -> {
|
||||
// Decline the knock request
|
||||
}
|
||||
is KnockRequestsCurrentAction.DeclineAndBan -> {
|
||||
// Decline and ban the user
|
||||
}
|
||||
is KnockRequestsCurrentAction.AcceptAll -> {
|
||||
// Accept all knock requests
|
||||
}
|
||||
KnockRequestsCurrentAction.None -> Unit
|
||||
}
|
||||
executeAction(currentAction, asyncAction, isActionConfirmed = false)
|
||||
}
|
||||
|
||||
return KnockRequestsListState(
|
||||
knockRequests = AsyncData.Success(persistentListOf()),
|
||||
currentAction = currentAction.value,
|
||||
canAccept = canAccept,
|
||||
canDecline = canDecline,
|
||||
canBan = canBan,
|
||||
knockRequests = knockRequests,
|
||||
currentAction = currentAction,
|
||||
permissions = permissions,
|
||||
asyncAction = asyncAction.value,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.executeAction(
|
||||
currentAction: KnockRequestsAction,
|
||||
asyncAction: MutableState<AsyncAction<Unit>>,
|
||||
isActionConfirmed: Boolean,
|
||||
) = launch {
|
||||
when (currentAction) {
|
||||
is KnockRequestsAction.Accept -> {
|
||||
runUpdatingState(asyncAction) {
|
||||
knockRequestsService.acceptKnockRequest(currentAction.knockRequest)
|
||||
}
|
||||
}
|
||||
is KnockRequestsAction.Decline -> {
|
||||
if (isActionConfirmed) {
|
||||
runUpdatingState(asyncAction) {
|
||||
knockRequestsService.declineKnockRequest(currentAction.knockRequest)
|
||||
}
|
||||
} else {
|
||||
asyncAction.value = AsyncAction.ConfirmingNoParams
|
||||
}
|
||||
}
|
||||
is KnockRequestsAction.DeclineAndBan -> {
|
||||
if (isActionConfirmed) {
|
||||
runUpdatingState(asyncAction) {
|
||||
knockRequestsService.declineAndBanKnockRequest(currentAction.knockRequest)
|
||||
}
|
||||
} else {
|
||||
asyncAction.value = AsyncAction.ConfirmingNoParams
|
||||
}
|
||||
}
|
||||
is KnockRequestsAction.AcceptAll -> {
|
||||
if (isActionConfirmed) {
|
||||
runUpdatingState(asyncAction) {
|
||||
knockRequestsService.acceptAllKnockRequests()
|
||||
}
|
||||
} else {
|
||||
asyncAction.value = AsyncAction.ConfirmingNoParams
|
||||
}
|
||||
}
|
||||
KnockRequestsAction.None -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,27 +8,27 @@
|
|||
package io.element.android.features.knockrequests.impl.list
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.knockrequests.impl.KnockRequest
|
||||
import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions
|
||||
import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class KnockRequestsListState(
|
||||
val knockRequests: AsyncData<ImmutableList<KnockRequest>>,
|
||||
val currentAction: KnockRequestsCurrentAction,
|
||||
val canAccept: Boolean,
|
||||
val canDecline: Boolean,
|
||||
val canBan: Boolean,
|
||||
val knockRequests: AsyncData<ImmutableList<KnockRequestPresentable>>,
|
||||
val currentAction: KnockRequestsAction,
|
||||
val asyncAction: AsyncAction<Unit>,
|
||||
val permissions: KnockRequestPermissions,
|
||||
val eventSink: (KnockRequestsListEvents) -> Unit,
|
||||
) {
|
||||
val canAcceptAll = knockRequests is AsyncData.Success && knockRequests.data.size > 1
|
||||
val canAcceptAll = permissions.canAccept && knockRequests is AsyncData.Success && knockRequests.data.size > 1
|
||||
}
|
||||
|
||||
@Immutable
|
||||
sealed interface KnockRequestsCurrentAction {
|
||||
data object None : KnockRequestsCurrentAction
|
||||
data class Accept(val knockRequest: KnockRequest, val async: AsyncAction<Unit>) : KnockRequestsCurrentAction
|
||||
data class Decline(val knockRequest: KnockRequest, val async: AsyncAction<Unit>) : KnockRequestsCurrentAction
|
||||
data class DeclineAndBan(val knockRequest: KnockRequest, val async: AsyncAction<Unit>) : KnockRequestsCurrentAction
|
||||
data class AcceptAll(val async: AsyncAction<Unit>) : KnockRequestsCurrentAction
|
||||
sealed interface KnockRequestsAction {
|
||||
data object None : KnockRequestsAction
|
||||
data class Accept(val knockRequest: KnockRequestPresentable) : KnockRequestsAction
|
||||
data class Decline(val knockRequest: KnockRequestPresentable) : KnockRequestsAction
|
||||
data class DeclineAndBan(val knockRequest: KnockRequestPresentable) : KnockRequestsAction
|
||||
data object AcceptAll : KnockRequestsAction
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@
|
|||
package io.element.android.features.knockrequests.impl.list
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.knockrequests.impl.KnockRequest
|
||||
import io.element.android.features.knockrequests.impl.aKnockRequest
|
||||
import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions
|
||||
import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
|
||||
import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
|
@ -30,14 +31,14 @@ open class KnockRequestsListStateProvider : PreviewParameterProvider<KnockReques
|
|||
aKnockRequestsListState(
|
||||
knockRequests = AsyncData.Success(
|
||||
persistentListOf(
|
||||
aKnockRequest()
|
||||
aKnockRequestPresentable()
|
||||
)
|
||||
),
|
||||
),
|
||||
aKnockRequestsListState(
|
||||
knockRequests = AsyncData.Success(
|
||||
persistentListOf(
|
||||
aKnockRequest(
|
||||
aKnockRequestPresentable(
|
||||
reason = "A very long reason that should probably be truncated, " +
|
||||
"but could be also expanded so you can see it over the lines, wow," +
|
||||
"very amazing reason, I know, right, I'm so good at writing reasons."
|
||||
|
|
@ -48,8 +49,8 @@ open class KnockRequestsListStateProvider : PreviewParameterProvider<KnockReques
|
|||
aKnockRequestsListState(
|
||||
knockRequests = AsyncData.Success(
|
||||
persistentListOf(
|
||||
aKnockRequest(),
|
||||
aKnockRequest(
|
||||
aKnockRequestPresentable(),
|
||||
aKnockRequestPresentable(
|
||||
userId = UserId("@user:example.com"),
|
||||
displayName = null,
|
||||
avatarUrl = null,
|
||||
|
|
@ -61,59 +62,88 @@ open class KnockRequestsListStateProvider : PreviewParameterProvider<KnockReques
|
|||
aKnockRequestsListState(
|
||||
knockRequests = AsyncData.Success(
|
||||
persistentListOf(
|
||||
aKnockRequest()
|
||||
aKnockRequestPresentable()
|
||||
)
|
||||
),
|
||||
currentAction = KnockRequestsCurrentAction.AcceptAll(AsyncAction.Loading),
|
||||
currentAction = KnockRequestsAction.AcceptAll,
|
||||
asyncAction = AsyncAction.ConfirmingNoParams,
|
||||
),
|
||||
aKnockRequestsListState(
|
||||
knockRequests = AsyncData.Success(
|
||||
persistentListOf(
|
||||
aKnockRequest()
|
||||
aKnockRequestPresentable()
|
||||
)
|
||||
),
|
||||
canAccept = false,
|
||||
currentAction = KnockRequestsAction.AcceptAll,
|
||||
asyncAction = AsyncAction.Loading,
|
||||
),
|
||||
aKnockRequestsListState(
|
||||
knockRequests = AsyncData.Success(
|
||||
persistentListOf(
|
||||
aKnockRequest()
|
||||
aKnockRequestPresentable()
|
||||
)
|
||||
),
|
||||
canDecline = false,
|
||||
permissions = KnockRequestPermissions(
|
||||
canAccept = false,
|
||||
canDecline = true,
|
||||
canBan = true,
|
||||
),
|
||||
currentAction = KnockRequestsAction.AcceptAll,
|
||||
asyncAction = AsyncAction.Failure(Throwable("Failed to accept all")),
|
||||
),
|
||||
aKnockRequestsListState(
|
||||
knockRequests = AsyncData.Success(
|
||||
persistentListOf(
|
||||
aKnockRequest()
|
||||
aKnockRequestPresentable()
|
||||
)
|
||||
),
|
||||
canAccept = false,
|
||||
canDecline = false,
|
||||
permissions = KnockRequestPermissions(
|
||||
canAccept = true,
|
||||
canDecline = false,
|
||||
canBan = true,
|
||||
),
|
||||
),
|
||||
aKnockRequestsListState(
|
||||
knockRequests = AsyncData.Success(
|
||||
persistentListOf(
|
||||
aKnockRequest()
|
||||
aKnockRequestPresentable()
|
||||
)
|
||||
),
|
||||
canBan = false,
|
||||
permissions = KnockRequestPermissions(
|
||||
canAccept = false,
|
||||
canDecline = false,
|
||||
canBan = true,
|
||||
),
|
||||
),
|
||||
aKnockRequestsListState(
|
||||
knockRequests = AsyncData.Success(
|
||||
persistentListOf(
|
||||
aKnockRequestPresentable()
|
||||
)
|
||||
),
|
||||
permissions = KnockRequestPermissions(
|
||||
canAccept = true,
|
||||
canDecline = true,
|
||||
canBan = false,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun aKnockRequestsListState(
|
||||
knockRequests: AsyncData<ImmutableList<KnockRequest>> = AsyncData.Success(persistentListOf()),
|
||||
currentAction: KnockRequestsCurrentAction = KnockRequestsCurrentAction.None,
|
||||
canAccept: Boolean = true,
|
||||
canDecline: Boolean = true,
|
||||
canBan: Boolean = true,
|
||||
knockRequests: AsyncData<ImmutableList<KnockRequestPresentable>> = AsyncData.Success(persistentListOf()),
|
||||
currentAction: KnockRequestsAction = KnockRequestsAction.None,
|
||||
asyncAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
permissions: KnockRequestPermissions = KnockRequestPermissions(
|
||||
canAccept = true,
|
||||
canDecline = true,
|
||||
canBan = true,
|
||||
),
|
||||
eventSink: (KnockRequestsListEvents) -> Unit = {},
|
||||
) = KnockRequestsListState(
|
||||
knockRequests = knockRequests,
|
||||
currentAction = currentAction,
|
||||
canAccept = canAccept,
|
||||
canDecline = canDecline,
|
||||
canBan = canBan,
|
||||
asyncAction = asyncAction,
|
||||
permissions = permissions,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import androidx.compose.animation.animateContentSize
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
|
|
@ -46,23 +47,25 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
|||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.knockrequests.impl.KnockRequest
|
||||
import io.element.android.features.knockrequests.impl.R
|
||||
import io.element.android.features.knockrequests.impl.getAvatarData
|
||||
import io.element.android.features.knockrequests.impl.getBestName
|
||||
import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||
import io.element.android.libraries.designsystem.components.BigIcon
|
||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncActionView
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.text.toDp
|
||||
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.ButtonSize
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
|
||||
|
|
@ -100,14 +103,18 @@ private fun KnockRequestsListContent(
|
|||
state: KnockRequestsListState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun onAcceptClick(knockRequest: KnockRequest) {
|
||||
fun onAcceptClick(knockRequest: KnockRequestPresentable) {
|
||||
state.eventSink(KnockRequestsListEvents.Accept(knockRequest))
|
||||
}
|
||||
|
||||
fun onDeclineClick(knockRequest: KnockRequest) {
|
||||
fun onDeclineClick(knockRequest: KnockRequestPresentable) {
|
||||
state.eventSink(KnockRequestsListEvents.Decline(knockRequest))
|
||||
}
|
||||
|
||||
fun onBanClick(knockRequest: KnockRequestPresentable) {
|
||||
state.eventSink(KnockRequestsListEvents.DeclineAndBan(knockRequest))
|
||||
}
|
||||
|
||||
var bottomPaddingInPixels by remember { mutableIntStateOf(0) }
|
||||
|
||||
Box(modifier.fillMaxSize()) {
|
||||
|
|
@ -119,21 +126,43 @@ private fun KnockRequestsListContent(
|
|||
} else {
|
||||
KnockRequestsList(
|
||||
knockRequests = knockRequests,
|
||||
canAccept = state.canAccept,
|
||||
canDecline = state.canDecline,
|
||||
canBan = state.canBan,
|
||||
canAccept = state.permissions.canAccept,
|
||||
canDecline = state.permissions.canDecline,
|
||||
canBan = state.permissions.canBan,
|
||||
onAcceptClick = ::onAcceptClick,
|
||||
onDeclineClick = ::onDeclineClick,
|
||||
onBanClick = ::onBanClick,
|
||||
contentPadding = PaddingValues(bottom = bottomPaddingInPixels.toDp()),
|
||||
)
|
||||
}
|
||||
}
|
||||
is AsyncData.Loading -> {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = spacedBy(16.dp),
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
) {
|
||||
CircularProgressIndicator(color = ElementTheme.colors.iconPrimary)
|
||||
Text(
|
||||
text = stringResource(R.string.screen_knock_requests_list_initial_loading_title),
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
KnockRequestsActionsView(
|
||||
actions = state.currentAction,
|
||||
currentAction = state.currentAction,
|
||||
asyncAction = state.asyncAction,
|
||||
onConfirm = {
|
||||
state.eventSink(KnockRequestsListEvents.ConfirmCurrentAction)
|
||||
},
|
||||
onRetry = {
|
||||
state.eventSink(KnockRequestsListEvents.RetryCurrentAction)
|
||||
},
|
||||
onDismiss = {
|
||||
state.eventSink(KnockRequestsListEvents.DismissCurrentAction)
|
||||
state.eventSink(KnockRequestsListEvents.ResetCurrentAction)
|
||||
},
|
||||
)
|
||||
if (state.canAcceptAll) {
|
||||
|
|
@ -152,53 +181,104 @@ private fun KnockRequestsListContent(
|
|||
|
||||
@Composable
|
||||
private fun KnockRequestsActionsView(
|
||||
actions: KnockRequestsCurrentAction,
|
||||
currentAction: KnockRequestsAction,
|
||||
asyncAction: AsyncAction<Unit>,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier) {
|
||||
when (actions) {
|
||||
is KnockRequestsCurrentAction.AcceptAll -> {
|
||||
AsyncActionView(
|
||||
async = actions.async,
|
||||
onSuccess = {},
|
||||
onErrorDismiss = onDismiss,
|
||||
AsyncActionView(
|
||||
async = asyncAction,
|
||||
onSuccess = { onDismiss() },
|
||||
onErrorDismiss = onDismiss,
|
||||
confirmationDialog = {
|
||||
KnockRequestActionConfirmation(
|
||||
currentAction = currentAction,
|
||||
onSubmit = onConfirm,
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
}
|
||||
is KnockRequestsCurrentAction.Accept -> {
|
||||
AsyncActionView(
|
||||
async = actions.async,
|
||||
onSuccess = {},
|
||||
onErrorDismiss = onDismiss,
|
||||
)
|
||||
}
|
||||
is KnockRequestsCurrentAction.Decline -> {
|
||||
AsyncActionView(
|
||||
async = actions.async,
|
||||
onSuccess = {},
|
||||
onErrorDismiss = onDismiss,
|
||||
)
|
||||
}
|
||||
is KnockRequestsCurrentAction.DeclineAndBan -> {
|
||||
AsyncActionView(
|
||||
async = actions.async,
|
||||
onSuccess = {},
|
||||
onErrorDismiss = onDismiss,
|
||||
)
|
||||
}
|
||||
KnockRequestsCurrentAction.None -> Unit
|
||||
}
|
||||
},
|
||||
progressDialog = {
|
||||
KnockRequestActionProgress(target = currentAction)
|
||||
},
|
||||
errorMessage = {
|
||||
when (currentAction) {
|
||||
is KnockRequestsAction.Accept -> stringResource(R.string.screen_knock_requests_list_accept_failed_alert_description)
|
||||
is KnockRequestsAction.Decline -> stringResource(R.string.screen_knock_requests_list_decline_failed_alert_description)
|
||||
is KnockRequestsAction.DeclineAndBan -> stringResource(R.string.screen_knock_requests_list_decline_failed_alert_description)
|
||||
KnockRequestsAction.AcceptAll -> stringResource(R.string.screen_knock_requests_list_accept_all_failed_alert_description)
|
||||
else -> ""
|
||||
}
|
||||
},
|
||||
onRetry = onRetry,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KnockRequestActionConfirmation(
|
||||
currentAction: KnockRequestsAction,
|
||||
onSubmit: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val (title, content, submitText) = when (currentAction) {
|
||||
KnockRequestsAction.AcceptAll -> Triple(
|
||||
stringResource(R.string.screen_knock_requests_list_accept_all_alert_title),
|
||||
stringResource(R.string.screen_knock_requests_list_accept_all_alert_description),
|
||||
stringResource(R.string.screen_knock_requests_list_accept_all_alert_confirm_button_title),
|
||||
)
|
||||
is KnockRequestsAction.Decline -> Triple(
|
||||
stringResource(R.string.screen_knock_requests_list_decline_alert_title),
|
||||
stringResource(R.string.screen_knock_requests_list_decline_alert_description, currentAction.knockRequest.getBestName()),
|
||||
stringResource(R.string.screen_knock_requests_list_decline_alert_confirm_button_title),
|
||||
)
|
||||
is KnockRequestsAction.DeclineAndBan -> Triple(
|
||||
stringResource(R.string.screen_knock_requests_list_ban_alert_title),
|
||||
stringResource(R.string.screen_knock_requests_list_ban_alert_description, currentAction.knockRequest.getBestName()),
|
||||
stringResource(R.string.screen_knock_requests_list_ban_alert_confirm_button_title),
|
||||
)
|
||||
else -> return
|
||||
}
|
||||
ConfirmationDialog(
|
||||
title = title,
|
||||
content = content,
|
||||
submitText = submitText,
|
||||
onSubmitClick = onSubmit,
|
||||
onDismiss = onDismiss,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KnockRequestActionProgress(
|
||||
target: KnockRequestsAction,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val progressText = when (target) {
|
||||
is KnockRequestsAction.Accept -> stringResource(R.string.screen_knock_requests_list_accept_loading_title)
|
||||
is KnockRequestsAction.Decline -> stringResource(R.string.screen_knock_requests_list_decline_loading_title)
|
||||
is KnockRequestsAction.DeclineAndBan -> stringResource(R.string.screen_knock_requests_list_ban_loading_title)
|
||||
KnockRequestsAction.AcceptAll -> stringResource(R.string.screen_knock_requests_list_accept_all_loading_title)
|
||||
else -> return
|
||||
}
|
||||
ProgressDialog(
|
||||
text = progressText,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KnockRequestsList(
|
||||
knockRequests: ImmutableList<KnockRequest>,
|
||||
knockRequests: ImmutableList<KnockRequestPresentable>,
|
||||
canAccept: Boolean,
|
||||
canDecline: Boolean,
|
||||
canBan: Boolean,
|
||||
onAcceptClick: (KnockRequest) -> Unit,
|
||||
onDeclineClick: (KnockRequest) -> Unit,
|
||||
onAcceptClick: (KnockRequestPresentable) -> Unit,
|
||||
onDeclineClick: (KnockRequestPresentable) -> Unit,
|
||||
onBanClick: (KnockRequestPresentable) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
) {
|
||||
|
|
@ -214,6 +294,7 @@ private fun KnockRequestsList(
|
|||
canDecline = canDecline,
|
||||
canAccept = canAccept,
|
||||
onDeclineClick = onDeclineClick,
|
||||
onBanClick = onBanClick,
|
||||
)
|
||||
if (index != knockRequests.size - 1) {
|
||||
HorizontalDivider()
|
||||
|
|
@ -224,12 +305,13 @@ private fun KnockRequestsList(
|
|||
|
||||
@Composable
|
||||
private fun KnockRequestItem(
|
||||
knockRequest: KnockRequest,
|
||||
knockRequest: KnockRequestPresentable,
|
||||
canAccept: Boolean,
|
||||
canDecline: Boolean,
|
||||
canBan: Boolean,
|
||||
onAcceptClick: (KnockRequest) -> Unit,
|
||||
onDeclineClick: (KnockRequest) -> Unit,
|
||||
onAcceptClick: (KnockRequestPresentable) -> Unit,
|
||||
onDeclineClick: (KnockRequestPresentable) -> Unit,
|
||||
onBanClick: (KnockRequestPresentable) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
|
|
@ -252,10 +334,11 @@ private fun KnockRequestItem(
|
|||
color = MaterialTheme.colorScheme.primary,
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
)
|
||||
if (!knockRequest.formattedDate.isNullOrEmpty()) {
|
||||
val formattedDate = knockRequest.formattedDate
|
||||
if (!formattedDate.isNullOrEmpty()) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = knockRequest.formattedDate,
|
||||
text = formattedDate,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
)
|
||||
|
|
@ -272,7 +355,8 @@ private fun KnockRequestItem(
|
|||
)
|
||||
}
|
||||
// Reason
|
||||
if (!knockRequest.reason.isNullOrBlank()) {
|
||||
val reason = knockRequest.reason
|
||||
if (!reason.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
var isExpanded by rememberSaveable(knockRequest.userId) { mutableStateOf(false) }
|
||||
var isExpandable by rememberSaveable(knockRequest.userId) { mutableStateOf(false) }
|
||||
|
|
@ -283,7 +367,7 @@ private fun KnockRequestItem(
|
|||
.clickable(enabled = isExpandable) { isExpanded = !isExpanded }
|
||||
) {
|
||||
Text(
|
||||
text = knockRequest.reason,
|
||||
text = reason,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
maxLines = if (isExpanded) Int.MAX_VALUE else 3,
|
||||
onTextLayout = { result ->
|
||||
|
|
@ -336,7 +420,7 @@ private fun KnockRequestItem(
|
|||
TextButton(
|
||||
text = stringResource(R.string.screen_knock_requests_list_decline_and_ban_action_title),
|
||||
onClick = {
|
||||
onAcceptClick(knockRequest)
|
||||
onBanClick(knockRequest)
|
||||
},
|
||||
destructive = true,
|
||||
size = ButtonSize.Small,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +36,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
|||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.StateEventType
|
||||
import io.element.android.libraries.matrix.api.room.isDm
|
||||
import io.element.android.libraries.matrix.api.room.join.JoinRule
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.canInvite
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.canSendState
|
||||
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
|
||||
|
|
@ -77,7 +78,7 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
val roomName by remember { derivedStateOf { (roomInfo?.name ?: room.displayName).trim() } }
|
||||
val roomTopic by remember { derivedStateOf { roomInfo?.topic ?: room.topic } }
|
||||
val isFavorite by remember { derivedStateOf { roomInfo?.isFavorite.orFalse() } }
|
||||
val isPublic by remember { derivedStateOf { roomInfo?.isPublic.orFalse() } }
|
||||
val joinRule by remember { derivedStateOf { roomInfo?.joinRule } }
|
||||
|
||||
val canShowPinnedMessages = isPinnedMessagesFeatureEnabled()
|
||||
var canShowMediaGallery by remember { mutableStateOf(false) }
|
||||
|
|
@ -106,11 +107,8 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
val roomType by getRoomType(dmMember, currentMember)
|
||||
val roomCallState = roomCallStatePresenter.present()
|
||||
|
||||
val canHandleKnockRequests by room.canHandleKnockRequestsAsState(syncUpdateFlow.value)
|
||||
|
||||
val topicState = remember(canEditTopic, roomTopic, roomType) {
|
||||
val topic = roomTopic
|
||||
|
||||
when {
|
||||
!topic.isNullOrBlank() -> RoomTopicState.ExistingTopic(topic)
|
||||
canEditTopic && roomType is RoomDetailsType.Room -> RoomTopicState.CanAddTopic
|
||||
|
|
@ -118,10 +116,13 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
val canHandleKnockRequests by room.canHandleKnockRequestsAsState(syncUpdateFlow.value)
|
||||
val isKnockRequestsEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock).collectAsState(false)
|
||||
val knockRequestsCount by remember { mutableStateOf(null) }
|
||||
val knockRequestsCount by produceState<Int?>(null) {
|
||||
room.knockRequestsFlow.collect { value = it.size }
|
||||
}
|
||||
val canShowKnockRequests by remember {
|
||||
derivedStateOf { isKnockRequestsEnabled && canHandleKnockRequests }
|
||||
derivedStateOf { isKnockRequestsEnabled && canHandleKnockRequests && joinRule == JoinRule.Knock }
|
||||
}
|
||||
|
||||
val roomNotificationSettingsState by room.roomNotificationSettingsStateFlow.collectAsState()
|
||||
|
|
@ -164,7 +165,7 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(),
|
||||
isFavorite = isFavorite,
|
||||
displayRolesAndPermissionsSettings = !room.isDm && isUserAdmin,
|
||||
isPublic = isPublic,
|
||||
isPublic = joinRule == JoinRule.Public,
|
||||
heroes = roomInfo?.heroes.orEmpty().toPersistentList(),
|
||||
canShowPinnedMessages = canShowPinnedMessages,
|
||||
canShowMediaGallery = canShowMediaGallery,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import io.element.android.libraries.matrix.api.media.MediaUploadHandler
|
|||
import io.element.android.libraries.matrix.api.media.VideoInfo
|
||||
import io.element.android.libraries.matrix.api.poll.PollKind
|
||||
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
|
||||
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
|
||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
|
||||
|
|
@ -32,6 +33,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
|
|||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import java.io.Closeable
|
||||
|
|
@ -52,10 +54,17 @@ interface MatrixRoom : Closeable {
|
|||
val activeMemberCount: Long
|
||||
val joinedMemberCount: Long
|
||||
|
||||
val roomCoroutineScope: CoroutineScope
|
||||
|
||||
val roomInfoFlow: Flow<MatrixRoomInfo>
|
||||
val roomTypingMembersFlow: Flow<List<UserId>>
|
||||
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.
|
||||
* See [the Matrix spec](https://spec.matrix.org/latest/client-server-api/#default-underride-rules).
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import io.element.android.libraries.matrix.api.core.EventId
|
|||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.join.JoinRule
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
|
|
@ -27,6 +28,7 @@ data class MatrixRoomInfo(
|
|||
val avatarUrl: String?,
|
||||
val isDirect: Boolean,
|
||||
val isPublic: Boolean,
|
||||
val joinRule: JoinRule?,
|
||||
val isSpace: Boolean,
|
||||
val isTombstoned: Boolean,
|
||||
val isFavorite: Boolean,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
|||
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.impl.room.join.map
|
||||
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
|
@ -36,6 +37,7 @@ class MatrixRoomInfoMapper {
|
|||
avatarUrl = it.avatarUrl,
|
||||
isDirect = it.isDirect,
|
||||
isPublic = it.isPublic,
|
||||
joinRule = it.joinRule?.map(),
|
||||
isSpace = it.isSpace,
|
||||
isTombstoned = it.isTombstoned,
|
||||
isFavorite = it.isFavourite,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import io.element.android.libraries.matrix.api.room.RoomMember
|
|||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||
import io.element.android.libraries.matrix.api.room.StateEventType
|
||||
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
|
||||
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
|
||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
|
||||
|
|
@ -50,6 +51,7 @@ import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
|
|||
import io.element.android.libraries.matrix.impl.core.RustSendHandle
|
||||
import io.element.android.libraries.matrix.impl.mapper.map
|
||||
import io.element.android.libraries.matrix.impl.room.draft.into
|
||||
import io.element.android.libraries.matrix.impl.room.knock.RustKnockRequest
|
||||
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
|
||||
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
|
||||
import io.element.android.libraries.matrix.impl.room.powerlevels.RoomPowerLevelsMapper
|
||||
|
|
@ -76,6 +78,7 @@ import kotlinx.coroutines.flow.onStart
|
|||
import kotlinx.coroutines.withContext
|
||||
import org.matrix.rustcomponents.sdk.DateDividerMode
|
||||
import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener
|
||||
import org.matrix.rustcomponents.sdk.KnockRequestsListener
|
||||
import org.matrix.rustcomponents.sdk.RoomInfo
|
||||
import org.matrix.rustcomponents.sdk.RoomInfoListener
|
||||
import org.matrix.rustcomponents.sdk.RoomListItem
|
||||
|
|
@ -91,6 +94,7 @@ import uniffi.matrix_sdk.RoomPowerLevelChanges
|
|||
import java.io.File
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import org.matrix.rustcomponents.sdk.IdentityStatusChange as RustIdentityStateChange
|
||||
import org.matrix.rustcomponents.sdk.KnockRequest as InnerKnockRequest
|
||||
import org.matrix.rustcomponents.sdk.Room as InnerRoom
|
||||
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
|
||||
|
||||
|
|
@ -157,13 +161,22 @@ class RustMatrixRoom(
|
|||
})
|
||||
}
|
||||
|
||||
override val knockRequestsFlow: Flow<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...
|
||||
private val roomDispatcher = coroutineDispatchers.io.limitedParallelism(32)
|
||||
|
||||
// ...except getMember methods as it could quickly fill the roomDispatcher...
|
||||
private val roomMembersDispatcher = coroutineDispatchers.io.limitedParallelism(8)
|
||||
|
||||
private val roomCoroutineScope = sessionCoroutineScope.childScope(coroutineDispatchers.main, "RoomScope-$roomId")
|
||||
override val roomCoroutineScope = sessionCoroutineScope.childScope(coroutineDispatchers.main, "RoomScope-$roomId")
|
||||
private val _syncUpdateFlow = MutableStateFlow(0L)
|
||||
private val roomMemberListFetcher = RoomMemberListFetcher(innerRoom, roomMembersDispatcher)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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() })
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import io.element.android.libraries.matrix.api.core.UserId
|
|||
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import io.element.android.libraries.matrix.api.room.join.JoinRule
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomHero
|
||||
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomInfo
|
||||
|
|
@ -31,6 +32,7 @@ import kotlinx.collections.immutable.toImmutableMap
|
|||
import kotlinx.collections.immutable.toPersistentList
|
||||
import org.junit.Test
|
||||
import org.matrix.rustcomponents.sdk.Membership
|
||||
import org.matrix.rustcomponents.sdk.JoinRule as RustJoinRule
|
||||
import org.matrix.rustcomponents.sdk.RoomNotificationMode as RustRoomNotificationMode
|
||||
|
||||
class MatrixRoomInfoMapperTest {
|
||||
|
|
@ -47,6 +49,7 @@ class MatrixRoomInfoMapperTest {
|
|||
isDirect = true,
|
||||
isPublic = false,
|
||||
isSpace = false,
|
||||
joinRule = RustJoinRule.Invite,
|
||||
isTombstoned = false,
|
||||
isFavourite = false,
|
||||
canonicalAlias = A_ROOM_ALIAS.value,
|
||||
|
|
@ -83,6 +86,7 @@ class MatrixRoomInfoMapperTest {
|
|||
isSpace = false,
|
||||
isTombstoned = false,
|
||||
isFavorite = false,
|
||||
joinRule = JoinRule.Invite,
|
||||
canonicalAlias = A_ROOM_ALIAS,
|
||||
alternativeAliases = listOf(A_ROOM_ALIAS).toImmutableList(),
|
||||
currentUserMembership = CurrentUserMembership.JOINED,
|
||||
|
|
@ -125,6 +129,7 @@ class MatrixRoomInfoMapperTest {
|
|||
avatarUrl = null,
|
||||
isDirect = false,
|
||||
isPublic = true,
|
||||
joinRule = null,
|
||||
isSpace = false,
|
||||
isTombstoned = false,
|
||||
isFavourite = true,
|
||||
|
|
@ -159,6 +164,7 @@ class MatrixRoomInfoMapperTest {
|
|||
avatarUrl = null,
|
||||
isDirect = false,
|
||||
isPublic = true,
|
||||
joinRule = null,
|
||||
isSpace = false,
|
||||
isTombstoned = false,
|
||||
isFavorite = true,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import io.element.android.libraries.matrix.api.room.MessageEventType
|
|||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.StateEventType
|
||||
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
|
||||
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
|
||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
|
||||
|
|
@ -48,12 +49,14 @@ import io.element.android.libraries.matrix.test.notificationsettings.FakeNotific
|
|||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.simulateLongTask
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import java.io.File
|
||||
|
||||
class FakeMatrixRoom(
|
||||
|
|
@ -72,6 +75,7 @@ class FakeMatrixRoom(
|
|||
override val activeMemberCount: Long = 234L,
|
||||
val notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(),
|
||||
override val liveTimeline: Timeline = FakeTimeline(),
|
||||
override val roomCoroutineScope: CoroutineScope = TestScope(),
|
||||
private var roomPermalinkResult: () -> Result<String> = { lambdaError() },
|
||||
private var eventPermalinkResult: (EventId) -> Result<String> = { lambdaError() },
|
||||
private val sendCallNotificationIfNeededResult: () -> Result<Unit> = { lambdaError() },
|
||||
|
|
@ -163,6 +167,13 @@ class FakeMatrixRoom(
|
|||
_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 roomNotificationSettingsStateFlow: MutableStateFlow<MatrixRoomNotificationSettingsState> =
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
|||
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import io.element.android.libraries.matrix.api.room.join.JoinRule
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
|
|
@ -33,6 +34,7 @@ fun aRoomInfo(
|
|||
avatarUrl: String? = AN_AVATAR_URL,
|
||||
isDirect: Boolean = false,
|
||||
isPublic: Boolean = true,
|
||||
joinRule: JoinRule? = JoinRule.Public,
|
||||
isSpace: Boolean = false,
|
||||
isTombstoned: Boolean = false,
|
||||
isFavorite: Boolean = false,
|
||||
|
|
@ -64,6 +66,7 @@ fun aRoomInfo(
|
|||
avatarUrl = avatarUrl,
|
||||
isDirect = isDirect,
|
||||
isPublic = isPublic,
|
||||
joinRule = joinRule,
|
||||
isSpace = isSpace,
|
||||
isTombstoned = isTombstoned,
|
||||
isFavorite = isFavorite,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
|||
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import io.element.android.libraries.matrix.api.room.join.JoinRule
|
||||
import io.element.android.libraries.matrix.api.room.message.RoomMessage
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
|
||||
|
|
@ -46,6 +47,7 @@ fun aRoomSummary(
|
|||
avatarUrl: String? = null,
|
||||
isDirect: Boolean = false,
|
||||
isPublic: Boolean = true,
|
||||
joinRule: JoinRule? = JoinRule.Public,
|
||||
isSpace: Boolean = false,
|
||||
isTombstoned: Boolean = false,
|
||||
isFavorite: Boolean = false,
|
||||
|
|
@ -79,6 +81,7 @@ fun aRoomSummary(
|
|||
avatarUrl = avatarUrl,
|
||||
isDirect = isDirect,
|
||||
isPublic = isPublic,
|
||||
joinRule = joinRule,
|
||||
isSpace = isSpace,
|
||||
isTombstoned = isTombstoned,
|
||||
isFavorite = isFavorite,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:77db58461ef35ea3d1ab1dda6aff454e376b15d7037e83fbbd965037afa45572
|
||||
size 29317
|
||||
oid sha256:74f93deb90501b746d95e0edf2ae2cef58036a388888e42a9b0fd8aadac9758c
|
||||
size 29447
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:585ea7b1f230ac6a12f8098200b04f433cbb30b39b8be7953dc6e278ffe8179e
|
||||
size 34799
|
||||
oid sha256:6a9a248862bd05327e10481f868c6dc10cc1ccad6a56284d497a9fb45f737206
|
||||
size 35042
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:05770e4e11bbc7019ebc113d31eb8b76bbbddc8b1ca6acbb01c0764089047376
|
||||
size 17835
|
||||
oid sha256:ef72766061f056096fc6d030b0e12d05dfa767ea5cac45d71b02cdbd0e78971b
|
||||
size 17859
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a10ccd7db4660bdff998622f5390a33adcaa39472c7d60ebae7c0a5b30a810d4
|
||||
size 27294
|
||||
oid sha256:3b98dc51e1f31bcd64dee223be7beb66b6ddd814189f290876633f5727d09f18
|
||||
size 27383
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:77db58461ef35ea3d1ab1dda6aff454e376b15d7037e83fbbd965037afa45572
|
||||
size 29317
|
||||
oid sha256:a386397f9a0f037050797980a3bd9482ec8a2f1a37d5e150ed8cdd50e5b576a1
|
||||
size 31873
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:77db58461ef35ea3d1ab1dda6aff454e376b15d7037e83fbbd965037afa45572
|
||||
size 29317
|
||||
oid sha256:1b0d57fed7af1716e23f9f1167f70199f5a21f60f3ac97940deccd824754a901
|
||||
size 39156
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:43e80c40ea0b0f6c02d03cf37537fa9e1b8ac71ec5f1d279b51477538a743063
|
||||
size 39209
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3f2c03edb4eaec4917b95955ac0b51cbdce25ae9710757cad68e3ed863707f7a
|
||||
size 27374
|
||||
oid sha256:1a7c6898ea6667e5b7f09ee99ca6b2d75ae3bc20e0db57b9d7a20c48d4681dca
|
||||
size 27308
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3811a02bebe20381a5996bcb61176d258fbb1ca4302cf5f9742470c3f152c780
|
||||
size 32258
|
||||
oid sha256:e229ac5d069412a18ae7deeefd0e88d9897b239ab32a0edd2c2d06f139f1f1af
|
||||
size 32430
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9aaf05a0f912cedf6897fac21e679429b86b0ed1d3e8b6a07cfe7ec6f60cec13
|
||||
size 15885
|
||||
oid sha256:62cf8a40ea8670ce9a23923d5791137ec057b130cde637c4dfb5b0edc75d7971
|
||||
size 15926
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:17d3263b1c47a75083bd597f73baf8cf5faa74274810e85cf2ee086812ca0e60
|
||||
size 25248
|
||||
oid sha256:89fdc1cf49c756e40d81eb858b507f751a6ff661fcb4f3194a5d7c0f738c1b94
|
||||
size 25250
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3f2c03edb4eaec4917b95955ac0b51cbdce25ae9710757cad68e3ed863707f7a
|
||||
size 27374
|
||||
oid sha256:ad9532960a251f04ec0fd513df561010a91923ab41de8318f99df23d3799feec
|
||||
size 28232
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3f2c03edb4eaec4917b95955ac0b51cbdce25ae9710757cad68e3ed863707f7a
|
||||
size 27374
|
||||
oid sha256:8f2419c455efb74f0f528017e411c16d76755646e3449ae4a62077857e3ea93b
|
||||
size 36848
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:cbed125bd5c334bb5d7fce60df615d188c713989bcd6450f59d3f37973ab2bc7
|
||||
size 36735
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fbbd687e5e0a1fd3a1be442937e32990dc72a6e4817d6ffc29f6f596eeddef1b
|
||||
size 8086
|
||||
oid sha256:0132466eb104c2249958a310b19336f97a5485f98e47a233c29740b14b9907ee
|
||||
size 14298
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9afb58cfcd13c064f0b17d449318d5111aaa914cb8875fe0596c66e5a8a17051
|
||||
size 30647
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f7e66d3f7d10e759f629795580545cda6644292020ce36763d07e9d476dcc231
|
||||
size 30325
|
||||
oid sha256:dcd89988740d57132796306568378471b67ceb7c182e82fce5fa01dc936a640f
|
||||
size 44821
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:586021e67718aa949b1de04799a43d9da8189fadacdb6dba23405c762fc7ee06
|
||||
size 30618
|
||||
oid sha256:7ee73fabc6f4db8b7fb26b606723c69cf169e3a003a828b74654b6286235e164
|
||||
size 34430
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1b71dc6dc0d29801e81d1d4e52df811624583f69c0510f2f650676747a326455
|
||||
size 30296
|
||||
oid sha256:170e1d045120d172d26d5d7bdd6bbf42c1a5bddcad7ff38d5352cd38c05efed4
|
||||
size 39551
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f6be2cfe7991bce6c622f4f2e45f4ac96810cdc9462d9a4d7aea11baf69ab5ac
|
||||
size 27446
|
||||
oid sha256:1b71dc6dc0d29801e81d1d4e52df811624583f69c0510f2f650676747a326455
|
||||
size 30296
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9afb58cfcd13c064f0b17d449318d5111aaa914cb8875fe0596c66e5a8a17051
|
||||
size 30647
|
||||
oid sha256:f6be2cfe7991bce6c622f4f2e45f4ac96810cdc9462d9a4d7aea11baf69ab5ac
|
||||
size 27446
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7ff4bc9e0588ea6a8396572b9ac9fb2401c3193fc3f1fbeb75efd69be6dda24d
|
||||
size 7867
|
||||
oid sha256:ab53aa77207ee6984eb5a7a619b5b748178819bd2006ace4282f28f1c6b16cac
|
||||
size 13944
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:68294db4cbd8bb42f4a0b6534b48585341beb2bc405e93cf74a2655f049522ff
|
||||
size 29768
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c30b6e25ec148d4f98a6c305b7d71af6c7e413cc3425f5a6f5b42a201039f491
|
||||
size 29007
|
||||
oid sha256:00c25643adf7f06ef46e824aa60b35348c18e596a82557b1546b55d29392aa11
|
||||
size 42613
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e5c6abc8bd5ca40eb91d926adc798cee95a8aacb0e87d937983c6240173fe2b9
|
||||
size 30071
|
||||
oid sha256:ad43b5aaa3ea7cf3ab79af88a2a93ba4294527ad2c40c72f4312f3e1b92b4f1a
|
||||
size 33280
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3b67c8d2cedb724ece75a2171463c4e035d562c4480d39b11b1072f4fdae3053
|
||||
size 29840
|
||||
oid sha256:6d9f612c2e6d46ed59ec5a3d1c65fe8953e4fc8f0955bddaa2abef6e1eabb00d
|
||||
size 37194
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:15903c9494100becc4dd2bc6e9fb7f38f8eebbb595cbc251cfc0f839d9a99a27
|
||||
size 27322
|
||||
oid sha256:3b67c8d2cedb724ece75a2171463c4e035d562c4480d39b11b1072f4fdae3053
|
||||
size 29840
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:68294db4cbd8bb42f4a0b6534b48585341beb2bc405e93cf74a2655f049522ff
|
||||
size 29768
|
||||
oid sha256:15903c9494100becc4dd2bc6e9fb7f38f8eebbb595cbc251cfc0f839d9a99a27
|
||||
size 27322
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue