diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/KnockRequest.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/KnockRequest.kt deleted file mode 100644 index 1014d13e58..0000000000 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/KnockRequest.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2024 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only - * Please see LICENSE in the repository root for full details. - */ - -package io.element.android.features.knockrequests.impl - -import io.element.android.libraries.designsystem.components.avatar.AvatarData -import io.element.android.libraries.designsystem.components.avatar.AvatarSize -import io.element.android.libraries.matrix.api.core.UserId - -data class KnockRequest( - val userId: UserId, - val displayName: String?, - val avatarUrl: String?, - val reason: String?, - val formattedDate: String?, -) - -fun KnockRequest.getAvatarData(size: AvatarSize) = AvatarData( - id = userId.value, - name = displayName, - url = avatarUrl, - size = size, -) - -fun KnockRequest.getBestName(): String { - return displayName?.takeIf { it.isNotEmpty() } ?: userId.value -} - -fun aKnockRequest( - userId: UserId = UserId("@jacob_ross:example.com"), - displayName: String? = "Jacob Ross", - avatarUrl: String? = null, - reason: String? = "Hi, I would like to get access to this room please.", - formattedDate: String = "20 Nov 2024", -) = KnockRequest( - userId = userId, - displayName = displayName, - avatarUrl = avatarUrl, - reason = reason, - formattedDate = formattedDate, -) diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenter.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenter.kt index a61236bbb0..c95fca6a5f 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenter.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenter.kt @@ -8,35 +8,89 @@ 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 io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.ui.room.canInviteAsState +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import javax.inject.Inject -class KnockRequestsBannerPresenter @Inject constructor() : Presenter { +private const val ACCEPT_ERROR_DISPLAY_DURATION = 1500L + +class KnockRequestsBannerPresenter @Inject constructor( + private val room: MatrixRoom, + private val knockRequestsService: KnockRequestsService, + private val appCoroutineScope: CoroutineScope, +) : Presenter { @Composable override fun present(): KnockRequestsBannerState { - var shouldShowBanner by remember { mutableStateOf(false) } + val knockRequests by remember { + knockRequestsService.knockRequestsFlow.mapState { knockRequests -> + knockRequests.dataOrNull().orEmpty() + .filter { !it.isSeen } + .toImmutableList() + } + }.collectAsState() + + val syncUpdateFlow = room.syncUpdateFlow.collectAsState() + val canAccept by room.canInviteAsState(syncUpdateFlow.value) + val showAcceptError = remember { mutableStateOf(false) } + + val shouldShowBanner by remember { + derivedStateOf { + 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 = canAccept, isVisible = shouldShowBanner, eventSink = ::handleEvents, ) } + + private fun CoroutineScope.acceptSingleKnockRequest( + knockRequests: List, + displayAcceptError: MutableState, + ) = launch { + val knockRequest = knockRequests.firstIfSingle() + if (knockRequest != null) { + knockRequestsService.acceptKnockRequest(knockRequest, optimistic = true) + .onFailure { + displayAcceptError.value = true + delay(ACCEPT_ERROR_DISPLAY_DURATION) + displayAcceptError.value = false + } + } + } } diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerState.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerState.kt index e65cd2fb26..9d53181e82 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerState.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerState.kt @@ -10,17 +10,15 @@ package io.element.android.features.knockrequests.impl.banner import androidx.compose.runtime.Composable import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import io.element.android.features.knockrequests.impl.KnockRequest +import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable 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.libraries.core.extensions.firstIfSingle import kotlinx.collections.immutable.ImmutableList data class KnockRequestsBannerState( val isVisible: Boolean, - val knockRequests: ImmutableList, - val acceptAction: AsyncAction, + val knockRequests: ImmutableList, + val displayAcceptError: Boolean, val canAccept: Boolean, val eventSink: (KnockRequestsBannerEvents) -> Unit, ) { diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerStateProvider.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerStateProvider.kt index c0f4cc0961..460d1c7462 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerStateProvider.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerStateProvider.kt @@ -8,9 +8,8 @@ package io.element.android.features.knockrequests.impl.banner import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.features.knockrequests.impl.KnockRequest -import io.element.android.features.knockrequests.impl.aKnockRequest -import io.element.android.libraries.architecture.AsyncAction +import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable +import io.element.android.features.knockrequests.impl.data.aKnockRequest import kotlinx.collections.immutable.toImmutableList class KnockRequestsBannerStateProvider : PreviewParameterProvider { @@ -44,10 +43,7 @@ class KnockRequestsBannerStateProvider : PreviewParameterProvider = listOf(aKnockRequest()), - acceptAction: AsyncAction = AsyncAction.Uninitialized, + knockRequests: List = listOf(aKnockRequest()), + displayAcceptError: Boolean = false, canAccept: Boolean = true, isVisible: Boolean = true, eventSink: (KnockRequestsBannerEvents) -> Unit = {} ) = KnockRequestsBannerState( knockRequests = knockRequests.toImmutableList(), - acceptAction = acceptAction, + displayAcceptError = displayAcceptError, canAccept = canAccept, isVisible = isVisible, eventSink = eventSink, diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerView.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerView.kt index 9cf7144f5e..69dfa268a6 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerView.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerView.kt @@ -20,9 +20,12 @@ 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.runtime.SideEffect import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset @@ -37,9 +40,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 @@ -52,6 +57,7 @@ import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList +import timber.log.Timber private const val MAX_AVATAR_COUNT = 3 @@ -61,22 +67,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 +122,9 @@ private fun KnockRequestsBannerContent( } Column( - modifier - .fillMaxWidth() - .padding(all = 16.dp) + modifier + .fillMaxWidth() + .padding(all = 16.dp) ) { Row { KnockRequestAvatarView( @@ -122,13 +148,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 +197,7 @@ private fun KnockRequestsBannerContent( @Composable private fun KnockRequestAvatarView( - knockRequests: ImmutableList, + knockRequests: ImmutableList, modifier: Modifier = Modifier, ) { Box(modifier) { @@ -183,7 +211,7 @@ private fun KnockRequestAvatarView( @Composable private fun KnockRequestAvatarListView( - knockRequests: ImmutableList, + knockRequests: ImmutableList, modifier: Modifier = Modifier, ) { val avatarSize = AvatarSize.KnockRequestBanner.dp @@ -198,27 +226,27 @@ private fun KnockRequestAvatarListView( smallReversedList.forEachIndexed { index, knockRequest -> Avatar( modifier = Modifier - .padding(start = avatarSize / 2 * (lastItemIndex - index)) - .graphicsLayer { - compositingStrategy = CompositingStrategy.Offscreen - } - .drawWithContent { - // Draw content and clear the pixels for the avatar on the left. - drawContent() - if (index < lastItemIndex) { - drawCircle( - color = Color.Black, - center = Offset( - x = 0f, - y = size.height / 2, - ), - radius = avatarSize.toPx() / 2, - blendMode = BlendMode.Clear, - ) + .padding(start = avatarSize / 2 * (lastItemIndex - index)) + .graphicsLayer { + compositingStrategy = CompositingStrategy.Offscreen } - } - .size(size = avatarSize) - .padding(2.dp), + .drawWithContent { + // Draw content and clear the pixels for the avatar on the left. + drawContent() + if (index < lastItemIndex) { + drawCircle( + color = Color.Black, + center = Offset( + x = 0f, + y = size.height / 2, + ), + radius = avatarSize.toPx() / 2, + blendMode = BlendMode.Clear, + ) + } + } + .size(size = avatarSize) + .padding(2.dp), avatarData = knockRequest.getAvatarData(AvatarSize.KnockRequestBanner), ) } diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestFixture.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestFixture.kt new file mode 100644 index 0000000000..5fbc2bb047 --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestFixture.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.data + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId + +fun aKnockRequest( + eventId: EventId = EventId("\$eventId"), + userId: UserId = UserId("@jacob_ross:example.com"), + displayName: String? = "Jacob Ross", + avatarUrl: String? = null, + reason: String? = "Hi, I would like to get access to this room please.", + formattedDate: String? = "20 Nov 2024", +) = object : KnockRequestPresentable { + override val eventId: EventId = eventId + override val userId: UserId = userId + override val displayName: String? = displayName + override val avatarUrl: String? = avatarUrl + override val reason: String? = reason + override val formattedDate: String? = formattedDate +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestPresentable.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestPresentable.kt new file mode 100644 index 0000000000..6716908a56 --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestPresentable.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.data + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId + +@Immutable +interface KnockRequestPresentable { + val eventId: EventId + val userId: UserId + val displayName: String? + val avatarUrl: String? + val reason: String? + val formattedDate: String? + + fun getAvatarData(size: AvatarSize) = AvatarData( + id = userId.value, + name = displayName, + url = avatarUrl, + size = size, + ) + + fun getBestName(): String { + return displayName?.takeIf { it.isNotEmpty() } ?: userId.value + } + +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestWrapper.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestWrapper.kt new file mode 100644 index 0000000000..56bcc34422 --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestWrapper.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.data + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.knock.KnockRequest + +class KnockRequestWrapper( + private val knockRequest: KnockRequest, + dateFormatter: (Long?) -> String? = { null } +) : KnockRequestPresentable { + override val eventId: EventId = knockRequest.eventId + override val userId: UserId = knockRequest.userId + override val displayName: String? = knockRequest.displayName + override val avatarUrl: String? = knockRequest.avatarUrl + override val reason: String? = knockRequest.reason?.trim() + override val formattedDate: String? = dateFormatter(knockRequest.timestamp) + + val isSeen: Boolean = knockRequest.isSeen + + suspend fun accept(): Result = knockRequest.accept() + + suspend fun decline(reason: String?): Result = knockRequest.decline(reason) + + suspend fun declineAndBan(reason: String?): Result = knockRequest.declineAndBan(reason) + + suspend fun markAsSeen(): Result = knockRequest.markAsSeen() +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsService.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsService.kt new file mode 100644 index 0000000000..653930b16b --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsService.kt @@ -0,0 +1,135 @@ +/* + * 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.di.RoomScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.room.MatrixRoom +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.supervisorScope +import javax.inject.Inject + +@SingleIn(RoomScope::class) +class KnockRequestsService @Inject constructor(room: MatrixRoom) { + + // Keep track of the knock requests that have been handled, so we don't have to wait for sync to remove them. + private val handledKnockRequestIds = MutableStateFlow>(emptySet()) + + val knockRequestsFlow = combine( + room.wrappedKnockRequestsFlow(), + handledKnockRequestIds, + ) { knockRequests, handledKnockIds -> + val presentableKnockRequests = knockRequests + .filter { it.eventId !in handledKnockIds } + .toImmutableList() + AsyncData.Success(presentableKnockRequests) + }.stateIn(room.roomCoroutineScope, SharingStarted.Lazily, AsyncData.Loading()) + + private fun knockRequestsList() = knockRequestsFlow.value.dataOrNull().orEmpty() + + private fun getKnockRequestById(eventId: EventId): KnockRequestWrapper? { + return knockRequestsList().find { it.eventId == eventId } + } + + /** + * Accept a knock request. + * @param knockRequest The knock request to accept. + * @param optimistic If true, the request will be marked as handled before the server responds. + */ + suspend fun acceptKnockRequest(knockRequest: KnockRequestPresentable, optimistic: Boolean = false): Result { + val wrapped = getKnockRequestById(knockRequest.eventId) ?: return knockRequestNotFoundResult() + return handleKnockRequest(wrapped, optimistic) { accept() } + } + + /** + * Decline a knock request. + * @param knockRequest The knock request to decline. + * @param optimistic If true, the request will be marked as handled before the server responds. + */ + suspend fun declineKnockRequest(knockRequest: KnockRequestPresentable, optimistic: Boolean = false): Result { + val wrapped = getKnockRequestById(knockRequest.eventId) ?: return knockRequestNotFoundResult() + return handleKnockRequest(wrapped, optimistic) { decline(null) } + } + + /** + * Decline a knock request by banning the user. + * @param knockRequest The knock request to decline. + * @param optimistic If true, the request will be marked as handled before the server responds. + */ + suspend fun declineAndBanKnockRequest(knockRequest: KnockRequestPresentable, optimistic: Boolean = false): Result { + val wrapped = getKnockRequestById(knockRequest.eventId) ?: return knockRequestNotFoundResult() + return handleKnockRequest(wrapped, optimistic) { declineAndBan(null) } + } + + /** + * Accept all currently known knock requests. + * @param optimistic If true, the requests will be marked as handled before the server responds. + */ + suspend fun acceptAllKnockRequests(optimistic: Boolean = false): Result = supervisorScope { + val results = knockRequestsList() + .map { knockRequest -> + async { + acceptKnockRequest(knockRequest, optimistic = optimistic) + } + } + .awaitAll() + if (results.all { it.isSuccess }) { + Result.success(Unit) + } else { + Result.failure(IllegalStateException("Failed to accept all knock requests")) + } + } + + /** + * Mark all currently known knock requests as seen. + */ + suspend fun markAllKnockRequestsAsSeen() = supervisorScope { + knockRequestsList() + .map { knockRequest -> + async { knockRequest.markAsSeen() } + } + .awaitAll() + } + + private suspend fun handleKnockRequest( + knockRequest: KnockRequestWrapper, + optimistic: Boolean, + action: suspend (KnockRequestWrapper.() -> Result) + ): Result { + if (optimistic) { + handledKnockRequestIds.getAndUpdate { it + knockRequest.eventId } + } + return action(knockRequest) + .onFailure { + if (optimistic) { + handledKnockRequestIds.getAndUpdate { it - knockRequest.eventId } + } + } + .onSuccess { + if (!optimistic) { + handledKnockRequestIds.getAndUpdate { it + knockRequest.eventId } + } + } + } + + private fun knockRequestNotFoundResult() = Result.failure(IllegalArgumentException("Knock request not found")) + + private fun MatrixRoom.wrappedKnockRequestsFlow() = knockRequestsFlow.map { knockRequests -> + knockRequests.map { KnockRequestWrapper(it) } + } +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListEvents.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListEvents.kt index 132c137ce2..23b1025ce2 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListEvents.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListEvents.kt @@ -7,12 +7,14 @@ package io.element.android.features.knockrequests.impl.list -import io.element.android.features.knockrequests.impl.KnockRequest +import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable sealed interface KnockRequestsListEvents { - data class Accept(val knockRequest: KnockRequest) : KnockRequestsListEvents - data class Decline(val knockRequest: KnockRequest) : KnockRequestsListEvents - data class DeclineAndBan(val knockRequest: KnockRequest) : KnockRequestsListEvents + data class Accept(val knockRequest: KnockRequestPresentable) : KnockRequestsListEvents + data class Decline(val knockRequest: KnockRequestPresentable) : KnockRequestsListEvents + data class DeclineAndBan(val knockRequest: KnockRequestPresentable) : KnockRequestsListEvents data object AcceptAll : KnockRequestsListEvents - data object DismissCurrentAction : KnockRequestsListEvents + data object ResetCurrentAction : KnockRequestsListEvents + data object RetryCurrentAction : KnockRequestsListEvents + data object ConfirmCurrentAction : KnockRequestsListEvents } diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenter.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenter.kt index fcafc5428b..04bc48dd4b 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenter.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenter.kt @@ -11,70 +11,109 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +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.architecture.runUpdatingState 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 javax.inject.Inject class KnockRequestsListPresenter @Inject constructor( private val room: MatrixRoom, + private val knockRequestsService: KnockRequestsService, ) : Presenter { @Composable override fun present(): KnockRequestsListState { - val currentAction = remember { mutableStateOf(KnockRequestsCurrentAction.None) } + val asyncAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } + var actionTarget by remember { mutableStateOf(KnockRequestsActionTarget.None) } + var targetActionConfirmed by remember { mutableStateOf(false) } + var retryCount by remember { mutableIntStateOf(0) } + 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 knockRequests by knockRequestsService.knockRequestsFlow.collectAsState() + fun handleEvents(event: KnockRequestsListEvents) { when (event) { KnockRequestsListEvents.AcceptAll -> { - currentAction.value = KnockRequestsCurrentAction.AcceptAll(AsyncAction.Uninitialized) + actionTarget = KnockRequestsActionTarget.AcceptAll } is KnockRequestsListEvents.Accept -> { - currentAction.value = KnockRequestsCurrentAction.Accept(event.knockRequest, AsyncAction.Uninitialized) + actionTarget = KnockRequestsActionTarget.Accept(event.knockRequest) } is KnockRequestsListEvents.Decline -> { - currentAction.value = KnockRequestsCurrentAction.Decline(event.knockRequest, AsyncAction.Uninitialized) + actionTarget = KnockRequestsActionTarget.Decline(event.knockRequest) } is KnockRequestsListEvents.DeclineAndBan -> { - currentAction.value = KnockRequestsCurrentAction.DeclineAndBan(event.knockRequest, AsyncAction.Uninitialized) + actionTarget = KnockRequestsActionTarget.DeclineAndBan(event.knockRequest) } - KnockRequestsListEvents.DismissCurrentAction -> { - currentAction.value = KnockRequestsCurrentAction.None + KnockRequestsListEvents.ResetCurrentAction -> { + actionTarget = KnockRequestsActionTarget.None + } + KnockRequestsListEvents.RetryCurrentAction -> { + retryCount++ + } + KnockRequestsListEvents.ConfirmCurrentAction -> { + targetActionConfirmed = true } } } - LaunchedEffect(currentAction) { - when (currentAction.value) { - is KnockRequestsCurrentAction.Accept -> { - // Accept the knock request + LaunchedEffect(actionTarget, targetActionConfirmed, retryCount) { + when (val action = actionTarget) { + is KnockRequestsActionTarget.Accept -> { + runUpdatingState(asyncAction) { + knockRequestsService.acceptKnockRequest(action.knockRequest) + } } - is KnockRequestsCurrentAction.Decline -> { - // Decline the knock request + is KnockRequestsActionTarget.Decline -> { + if (targetActionConfirmed) { + runUpdatingState(asyncAction) { + knockRequestsService.declineKnockRequest(action.knockRequest) + } + } else { + asyncAction.value = AsyncAction.ConfirmingNoParams + } } - is KnockRequestsCurrentAction.DeclineAndBan -> { - // Decline and ban the user + is KnockRequestsActionTarget.DeclineAndBan -> { + if (targetActionConfirmed) { + runUpdatingState(asyncAction) { + knockRequestsService.declineAndBanKnockRequest(action.knockRequest) + } + } else { + asyncAction.value = AsyncAction.ConfirmingNoParams + } } - is KnockRequestsCurrentAction.AcceptAll -> { - // Accept all knock requests + is KnockRequestsActionTarget.AcceptAll -> { + if (targetActionConfirmed) { + runUpdatingState(asyncAction) { + knockRequestsService.acceptAllKnockRequests() + } + } else { + asyncAction.value = AsyncAction.ConfirmingNoParams + } + } + KnockRequestsActionTarget.None -> { + targetActionConfirmed = false + asyncAction.value = AsyncAction.Uninitialized } - KnockRequestsCurrentAction.None -> Unit } } return KnockRequestsListState( - knockRequests = AsyncData.Success(persistentListOf()), - currentAction = currentAction.value, + knockRequests = knockRequests, + actionTarget = actionTarget, + asyncAction = asyncAction.value, canAccept = canAccept, canDecline = canDecline, canBan = canBan, diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.kt index 3ba10e9302..763305eca2 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.kt @@ -8,27 +8,28 @@ 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.KnockRequestPresentable import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import kotlinx.collections.immutable.ImmutableList data class KnockRequestsListState( - val knockRequests: AsyncData>, - val currentAction: KnockRequestsCurrentAction, + val knockRequests: AsyncData>, + val actionTarget: KnockRequestsActionTarget, + val asyncAction: AsyncAction, val canAccept: Boolean, val canDecline: Boolean, val canBan: Boolean, val eventSink: (KnockRequestsListEvents) -> Unit, ) { - val canAcceptAll = knockRequests is AsyncData.Success && knockRequests.data.size > 1 + val canAcceptAll = canAccept && knockRequests is AsyncData.Success && knockRequests.data.size > 1 } @Immutable -sealed interface KnockRequestsCurrentAction { - data object None : KnockRequestsCurrentAction - data class Accept(val knockRequest: KnockRequest, val async: AsyncAction) : KnockRequestsCurrentAction - data class Decline(val knockRequest: KnockRequest, val async: AsyncAction) : KnockRequestsCurrentAction - data class DeclineAndBan(val knockRequest: KnockRequest, val async: AsyncAction) : KnockRequestsCurrentAction - data class AcceptAll(val async: AsyncAction) : KnockRequestsCurrentAction +sealed interface KnockRequestsActionTarget { + data object None : KnockRequestsActionTarget + data class Accept(val knockRequest: KnockRequestPresentable) : KnockRequestsActionTarget + data class Decline(val knockRequest: KnockRequestPresentable) : KnockRequestsActionTarget + data class DeclineAndBan(val knockRequest: KnockRequestPresentable) : KnockRequestsActionTarget + data object AcceptAll : KnockRequestsActionTarget } diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt index 476bf556e1..e7207cced1 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt @@ -8,8 +8,8 @@ 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.KnockRequestPresentable +import io.element.android.features.knockrequests.impl.data.aKnockRequest import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.core.UserId @@ -64,7 +64,17 @@ open class KnockRequestsListStateProvider : PreviewParameterProvider> = AsyncData.Success(persistentListOf()), - currentAction: KnockRequestsCurrentAction = KnockRequestsCurrentAction.None, + knockRequests: AsyncData> = AsyncData.Success(persistentListOf()), + actionTarget: KnockRequestsActionTarget = KnockRequestsActionTarget.None, + asyncAction: AsyncAction = AsyncAction.Uninitialized, canAccept: Boolean = true, canDecline: Boolean = true, canBan: Boolean = true, eventSink: (KnockRequestsListEvents) -> Unit = {}, ) = KnockRequestsListState( knockRequests = knockRequests, - currentAction = currentAction, + actionTarget = actionTarget, + asyncAction = asyncAction, canAccept = canAccept, canDecline = canDecline, canBan = canBan, diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListView.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListView.kt index 41b5553438..58ac4984f4 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListView.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListView.kt @@ -46,23 +46,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 @@ -70,6 +72,7 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList @@ -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()) { @@ -124,16 +131,30 @@ private fun KnockRequestsListContent( canBan = state.canBan, onAcceptClick = ::onAcceptClick, onDeclineClick = ::onDeclineClick, + onBanClick = ::onBanClick, contentPadding = PaddingValues(bottom = bottomPaddingInPixels.toDp()), ) } } + is AsyncData.Loading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = ElementTheme.colors.iconPrimary, + ) + } else -> Unit } KnockRequestsActionsView( - actions = state.currentAction, + actionTarget = state.actionTarget, + 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 +173,45 @@ private fun KnockRequestsListContent( @Composable private fun KnockRequestsActionsView( - actions: KnockRequestsCurrentAction, + actionTarget: KnockRequestsActionTarget, + asyncAction: AsyncAction, + onConfirm: () -> Unit, onDismiss: () -> Unit, + onRetry: () -> Unit, modifier: Modifier = Modifier, ) { Box(modifier) { - when (actions) { - is KnockRequestsCurrentAction.AcceptAll -> { - AsyncActionView( - async = actions.async, - onSuccess = {}, - onErrorDismiss = onDismiss, + AsyncActionView( + async = asyncAction, + onSuccess = { onDismiss() }, + onErrorDismiss = onDismiss, + confirmationDialog = { + ConfirmationDialog( + title = "Confirmation", + content = "Are you sure?", + onSubmitClick = onConfirm, + onDismiss = onDismiss, ) - } - is KnockRequestsCurrentAction.Accept -> { - AsyncActionView( - async = actions.async, - onSuccess = {}, - onErrorDismiss = onDismiss, + }, + progressDialog = { + ProgressDialog( + text = "Loading", ) - } - is KnockRequestsCurrentAction.Decline -> { - AsyncActionView( - async = actions.async, - onSuccess = {}, - onErrorDismiss = onDismiss, - ) - } - is KnockRequestsCurrentAction.DeclineAndBan -> { - AsyncActionView( - async = actions.async, - onSuccess = {}, - onErrorDismiss = onDismiss, - ) - } - KnockRequestsCurrentAction.None -> Unit - } + }, + onRetry = onRetry, + ) } } @Composable private fun KnockRequestsList( - knockRequests: ImmutableList, + knockRequests: ImmutableList, canAccept: Boolean, canDecline: Boolean, canBan: Boolean, - onAcceptClick: (KnockRequest) -> Unit, - onDeclineClick: (KnockRequest) -> Unit, + onAcceptClick: (KnockRequestPresentable) -> Unit, + onDeclineClick: (KnockRequestPresentable) -> Unit, + onBanClick: (KnockRequestPresentable) -> Unit, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(0.dp), ) { @@ -214,6 +227,7 @@ private fun KnockRequestsList( canDecline = canDecline, canAccept = canAccept, onDeclineClick = onDeclineClick, + onBanClick = onBanClick, ) if (index != knockRequests.size - 1) { HorizontalDivider() @@ -224,12 +238,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 +267,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 +288,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 +300,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 +353,7 @@ private fun KnockRequestItem( TextButton( text = stringResource(R.string.screen_knock_requests_list_decline_and_ban_action_title), onClick = { - onAcceptClick(knockRequest) + onBanClick(knockRequest) }, destructive = true, size = ButtonSize.Small, diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt index 82ea24e7ff..d028c609ed 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt @@ -33,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 @@ -53,6 +54,8 @@ interface MatrixRoom : Closeable { val activeMemberCount: Long val joinedMemberCount: Long + val roomCoroutineScope: CoroutineScope + val roomInfoFlow: Flow val roomTypingMembersFlow: Flow> val identityStateChangesFlow: Flow> diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/knock/KnockRequest.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/knock/KnockRequest.kt index f5b25b5f2c..e7a0488245 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/knock/KnockRequest.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/knock/KnockRequest.kt @@ -7,14 +7,17 @@ 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 diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index 44f588bd83..588927f434 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -79,7 +79,7 @@ import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.DateDividerMode import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener import org.matrix.rustcomponents.sdk.JoinRequest -import org.matrix.rustcomponents.sdk.RequestsToJoinListener +import org.matrix.rustcomponents.sdk.JoinRequestsListener import org.matrix.rustcomponents.sdk.RoomInfo import org.matrix.rustcomponents.sdk.RoomInfoListener import org.matrix.rustcomponents.sdk.RoomListItem @@ -162,7 +162,7 @@ class RustMatrixRoom( } override val knockRequestsFlow: Flow> = mxCallbackFlow { - innerRoom.subscribeToJoinRequests(object : RequestsToJoinListener { + innerRoom.subscribeToJoinRequests(object : JoinRequestsListener { override fun call(joinRequests: List) { val knockRequests = joinRequests.map { RustKnockRequest(it) } channel.trySend(knockRequests) @@ -176,7 +176,7 @@ class RustMatrixRoom( // ...except getMember methods as it could quickly fill the roomDispatcher... private val roomMembersDispatcher = coroutineDispatchers.io.limitedParallelism(8) - private val roomCoroutineScope = sessionCoroutineScope.childScope(coroutineDispatchers.main, "RoomScope-$roomId") + override val roomCoroutineScope = sessionCoroutineScope.childScope(coroutineDispatchers.main, "RoomScope-$roomId") private val _syncUpdateFlow = MutableStateFlow(0L) private val roomMemberListFetcher = RoomMemberListFetcher(innerRoom, roomMembersDispatcher) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/knock/RustKnockRequest.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/knock/RustKnockRequest.kt index a3d133d599..8f7b075511 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/knock/RustKnockRequest.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/knock/RustKnockRequest.kt @@ -7,6 +7,7 @@ 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.JoinRequest @@ -14,11 +15,13 @@ import org.matrix.rustcomponents.sdk.JoinRequest class RustKnockRequest( private val inner: JoinRequest, ) : KnockRequest { + override val eventId: EventId = EventId(inner.eventId) override val userId: UserId = UserId(inner.userId) override val displayName: String? = inner.displayName override val avatarUrl: String? = inner.avatarUrl override val reason: String? = inner.reason override val timestamp: Long? = inner.timestamp?.toLong() + override val isSeen: Boolean = inner.isSeen override suspend fun accept(): Result = runCatching { inner.actions.accept() diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index cfb73e267e..18b804455d 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -49,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( @@ -73,6 +75,7 @@ class FakeMatrixRoom( override val activeMemberCount: Long = 234L, val notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(), override val liveTimeline: Timeline = FakeTimeline(), + override val roomCoroutineScope: CoroutineScope = TestScope(), private var roomPermalinkResult: () -> Result = { lambdaError() }, private var eventPermalinkResult: (EventId) -> Result = { lambdaError() }, private val sendCallNotificationIfNeededResult: () -> Result = { lambdaError() },