knock requests : branch the api in presenters

This commit is contained in:
ganfra 2024-12-11 18:02:35 +01:00
parent 741ae35aca
commit 8a73a9c158
19 changed files with 555 additions and 207 deletions

View file

@ -1,45 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.knockrequests.impl
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.UserId
data class KnockRequest(
val userId: UserId,
val displayName: String?,
val avatarUrl: String?,
val reason: String?,
val formattedDate: String?,
)
fun KnockRequest.getAvatarData(size: AvatarSize) = AvatarData(
id = userId.value,
name = displayName,
url = avatarUrl,
size = size,
)
fun KnockRequest.getBestName(): String {
return displayName?.takeIf { it.isNotEmpty() } ?: userId.value
}
fun aKnockRequest(
userId: UserId = UserId("@jacob_ross:example.com"),
displayName: String? = "Jacob Ross",
avatarUrl: String? = null,
reason: String? = "Hi, I would like to get access to this room please.",
formattedDate: String = "20 Nov 2024",
) = KnockRequest(
userId = userId,
displayName = displayName,
avatarUrl = avatarUrl,
reason = reason,
formattedDate = formattedDate,
)

View file

@ -8,35 +8,89 @@
package io.element.android.features.knockrequests.impl.banner package io.element.android.features.knockrequests.impl.banner
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
import io.element.android.libraries.architecture.AsyncAction import io.element.android.features.knockrequests.impl.data.KnockRequestsService
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import kotlinx.collections.immutable.persistentListOf import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.core.extensions.firstIfSingle
import 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 import javax.inject.Inject
class KnockRequestsBannerPresenter @Inject constructor() : Presenter<KnockRequestsBannerState> { 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<KnockRequestsBannerState> {
@Composable @Composable
override fun present(): KnockRequestsBannerState { override fun present(): KnockRequestsBannerState {
var shouldShowBanner by remember { mutableStateOf(false) } val knockRequests by remember {
knockRequestsService.knockRequestsFlow.mapState { knockRequests ->
knockRequests.dataOrNull().orEmpty()
.filter { !it.isSeen }
.toImmutableList()
}
}.collectAsState()
val 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) { fun handleEvents(event: KnockRequestsBannerEvents) {
when (event) { when (event) {
is KnockRequestsBannerEvents.AcceptSingleRequest -> Unit is KnockRequestsBannerEvents.AcceptSingleRequest -> {
appCoroutineScope.acceptSingleKnockRequest(
knockRequests = knockRequests,
displayAcceptError = showAcceptError,
)
}
is KnockRequestsBannerEvents.Dismiss -> { is KnockRequestsBannerEvents.Dismiss -> {
shouldShowBanner = false appCoroutineScope.launch {
knockRequestsService.markAllKnockRequestsAsSeen()
}
} }
} }
} }
return KnockRequestsBannerState( return KnockRequestsBannerState(
knockRequests = persistentListOf(), knockRequests = knockRequests,
acceptAction = AsyncAction.Uninitialized, displayAcceptError = showAcceptError.value,
canAccept = false, canAccept = canAccept,
isVisible = shouldShowBanner, isVisible = shouldShowBanner,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }
private fun CoroutineScope.acceptSingleKnockRequest(
knockRequests: List<KnockRequestPresentable>,
displayAcceptError: MutableState<Boolean>,
) = launch {
val knockRequest = knockRequests.firstIfSingle()
if (knockRequest != null) {
knockRequestsService.acceptKnockRequest(knockRequest, optimistic = true)
.onFailure {
displayAcceptError.value = true
delay(ACCEPT_ERROR_DISPLAY_DURATION)
displayAcceptError.value = false
}
}
}
} }

View file

@ -10,17 +10,15 @@ package io.element.android.features.knockrequests.impl.banner
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import io.element.android.features.knockrequests.impl.KnockRequest import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
import io.element.android.features.knockrequests.impl.R import io.element.android.features.knockrequests.impl.R
import io.element.android.features.knockrequests.impl.getBestName
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.core.extensions.firstIfSingle import io.element.android.libraries.core.extensions.firstIfSingle
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class KnockRequestsBannerState( data class KnockRequestsBannerState(
val isVisible: Boolean, val isVisible: Boolean,
val knockRequests: ImmutableList<KnockRequest>, val knockRequests: ImmutableList<KnockRequestPresentable>,
val acceptAction: AsyncAction<Unit>, val displayAcceptError: Boolean,
val canAccept: Boolean, val canAccept: Boolean,
val eventSink: (KnockRequestsBannerEvents) -> Unit, val eventSink: (KnockRequestsBannerEvents) -> Unit,
) { ) {

View file

@ -8,9 +8,8 @@
package io.element.android.features.knockrequests.impl.banner package io.element.android.features.knockrequests.impl.banner
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.knockrequests.impl.KnockRequest import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
import io.element.android.features.knockrequests.impl.aKnockRequest import io.element.android.features.knockrequests.impl.data.aKnockRequest
import io.element.android.libraries.architecture.AsyncAction
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
class KnockRequestsBannerStateProvider : PreviewParameterProvider<KnockRequestsBannerState> { class KnockRequestsBannerStateProvider : PreviewParameterProvider<KnockRequestsBannerState> {
@ -44,10 +43,7 @@ class KnockRequestsBannerStateProvider : PreviewParameterProvider<KnockRequestsB
canAccept = false canAccept = false
), ),
aKnockRequestsBannerState( aKnockRequestsBannerState(
acceptAction = AsyncAction.Loading displayAcceptError = true
),
aKnockRequestsBannerState(
acceptAction = AsyncAction.Failure(Throwable("Failed to accept knock"))
), ),
aKnockRequestsBannerState( aKnockRequestsBannerState(
knockRequests = listOf( knockRequests = listOf(
@ -60,14 +56,14 @@ class KnockRequestsBannerStateProvider : PreviewParameterProvider<KnockRequestsB
} }
fun aKnockRequestsBannerState( fun aKnockRequestsBannerState(
knockRequests: List<KnockRequest> = listOf(aKnockRequest()), knockRequests: List<KnockRequestPresentable> = listOf(aKnockRequest()),
acceptAction: AsyncAction<Unit> = AsyncAction.Uninitialized, displayAcceptError: Boolean = false,
canAccept: Boolean = true, canAccept: Boolean = true,
isVisible: Boolean = true, isVisible: Boolean = true,
eventSink: (KnockRequestsBannerEvents) -> Unit = {} eventSink: (KnockRequestsBannerEvents) -> Unit = {}
) = KnockRequestsBannerState( ) = KnockRequestsBannerState(
knockRequests = knockRequests.toImmutableList(), knockRequests = knockRequests.toImmutableList(),
acceptAction = acceptAction, displayAcceptError = displayAcceptError,
canAccept = canAccept, canAccept = canAccept,
isVisible = isVisible, isVisible = isVisible,
eventSink = eventSink, eventSink = eventSink,

View file

@ -20,9 +20,12 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
@ -37,9 +40,11 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.knockrequests.impl.KnockRequest
import io.element.android.features.knockrequests.impl.R import io.element.android.features.knockrequests.impl.R
import io.element.android.features.knockrequests.impl.getAvatarData import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost
import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@ -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.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import timber.log.Timber
private const val MAX_AVATAR_COUNT = 3 private const val MAX_AVATAR_COUNT = 3
@ -61,22 +67,42 @@ fun KnockRequestsBannerView(
onViewRequestsClick: () -> Unit, onViewRequestsClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
AnimatedVisibility( Box(modifier = modifier) {
visible = state.isVisible, AnimatedVisibility(
enter = expandVertically(), visible = state.isVisible,
exit = shrinkVertically(), enter = expandVertically(),
modifier = modifier, exit = shrinkVertically(),
) {
Surface(
shape = MaterialTheme.shapes.small,
color = ElementTheme.colors.bgCanvasDefaultLevel1,
shadowElevation = 24.dp,
modifier = Modifier.padding(16.dp),
) { ) {
KnockRequestsBannerContent( Surface(
state = state, shape = MaterialTheme.shapes.small,
onViewRequestsClick = onViewRequestsClick, color = ElementTheme.colors.bgCanvasDefaultLevel1,
) shadowElevation = 24.dp,
modifier = Modifier.padding(16.dp),
) {
KnockRequestsBannerContent(
state = state,
onViewRequestsClick = onViewRequestsClick,
)
}
}
KnockRequestsAcceptErrorView(displayError = state.displayAcceptError)
}
}
@Composable
private fun KnockRequestsAcceptErrorView(
displayError: Boolean,
modifier: Modifier = Modifier,
) {
val asyncIndicatorState = rememberAsyncIndicatorState()
AsyncIndicatorHost(modifier = modifier.statusBarsPadding(), state = asyncIndicatorState)
LaunchedEffect(displayError) {
if (displayError) {
asyncIndicatorState.enqueue {
AsyncIndicator.Custom(text = stringResource(CommonStrings.error_unknown))
}
} else {
asyncIndicatorState.clear()
} }
} }
} }
@ -96,9 +122,9 @@ private fun KnockRequestsBannerContent(
} }
Column( Column(
modifier modifier
.fillMaxWidth() .fillMaxWidth()
.padding(all = 16.dp) .padding(all = 16.dp)
) { ) {
Row { Row {
KnockRequestAvatarView( KnockRequestAvatarView(
@ -122,13 +148,15 @@ private fun KnockRequestsBannerContent(
) )
} }
} }
Spacer(modifier = Modifier.width(4.dp))
Icon( Icon(
modifier = Modifier.clickable(onClick = ::onDismissClick), modifier = Modifier.clickable(onClick = ::onDismissClick),
imageVector = CompoundIcons.Close(), imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_close) contentDescription = stringResource(CommonStrings.action_close)
) )
} }
if (state.reason != null) { val reason = state.reason
if (!reason.isNullOrEmpty()) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = state.reason, text = state.reason,
@ -169,7 +197,7 @@ private fun KnockRequestsBannerContent(
@Composable @Composable
private fun KnockRequestAvatarView( private fun KnockRequestAvatarView(
knockRequests: ImmutableList<KnockRequest>, knockRequests: ImmutableList<KnockRequestPresentable>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Box(modifier) { Box(modifier) {
@ -183,7 +211,7 @@ private fun KnockRequestAvatarView(
@Composable @Composable
private fun KnockRequestAvatarListView( private fun KnockRequestAvatarListView(
knockRequests: ImmutableList<KnockRequest>, knockRequests: ImmutableList<KnockRequestPresentable>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val avatarSize = AvatarSize.KnockRequestBanner.dp val avatarSize = AvatarSize.KnockRequestBanner.dp
@ -198,27 +226,27 @@ private fun KnockRequestAvatarListView(
smallReversedList.forEachIndexed { index, knockRequest -> smallReversedList.forEachIndexed { index, knockRequest ->
Avatar( Avatar(
modifier = Modifier modifier = Modifier
.padding(start = avatarSize / 2 * (lastItemIndex - index)) .padding(start = avatarSize / 2 * (lastItemIndex - index))
.graphicsLayer { .graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
// Draw content and clear the pixels for the avatar on the left.
drawContent()
if (index < lastItemIndex) {
drawCircle(
color = Color.Black,
center = Offset(
x = 0f,
y = size.height / 2,
),
radius = avatarSize.toPx() / 2,
blendMode = BlendMode.Clear,
)
} }
} .drawWithContent {
.size(size = avatarSize) // Draw content and clear the pixels for the avatar on the left.
.padding(2.dp), drawContent()
if (index < lastItemIndex) {
drawCircle(
color = Color.Black,
center = Offset(
x = 0f,
y = size.height / 2,
),
radius = avatarSize.toPx() / 2,
blendMode = BlendMode.Clear,
)
}
}
.size(size = avatarSize)
.padding(2.dp),
avatarData = knockRequest.getAvatarData(AvatarSize.KnockRequestBanner), avatarData = knockRequest.getAvatarData(AvatarSize.KnockRequestBanner),
) )
} }

View file

@ -0,0 +1,27 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.knockrequests.impl.data
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
fun 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
}

View file

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

View file

@ -0,0 +1,34 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.knockrequests.impl.data
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
class KnockRequestWrapper(
private val 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<Unit> = knockRequest.accept()
suspend fun decline(reason: String?): Result<Unit> = knockRequest.decline(reason)
suspend fun declineAndBan(reason: String?): Result<Unit> = knockRequest.declineAndBan(reason)
suspend fun markAsSeen(): Result<Unit> = knockRequest.markAsSeen()
}

View file

@ -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<Set<EventId>>(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<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(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<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>(IllegalArgumentException("Knock request not found"))
private fun MatrixRoom.wrappedKnockRequestsFlow() = knockRequestsFlow.map { knockRequests ->
knockRequests.map { KnockRequestWrapper(it) }
}
}

View file

@ -7,12 +7,14 @@
package io.element.android.features.knockrequests.impl.list package io.element.android.features.knockrequests.impl.list
import io.element.android.features.knockrequests.impl.KnockRequest import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
sealed interface KnockRequestsListEvents { sealed interface KnockRequestsListEvents {
data class Accept(val knockRequest: KnockRequest) : KnockRequestsListEvents data class Accept(val knockRequest: KnockRequestPresentable) : KnockRequestsListEvents
data class Decline(val knockRequest: KnockRequest) : KnockRequestsListEvents data class Decline(val knockRequest: KnockRequestPresentable) : KnockRequestsListEvents
data class DeclineAndBan(val knockRequest: KnockRequest) : KnockRequestsListEvents data class DeclineAndBan(val knockRequest: KnockRequestPresentable) : KnockRequestsListEvents
data object AcceptAll : KnockRequestsListEvents data object AcceptAll : KnockRequestsListEvents
data object DismissCurrentAction : KnockRequestsListEvents data object ResetCurrentAction : KnockRequestsListEvents
data object RetryCurrentAction : KnockRequestsListEvents
data object ConfirmCurrentAction : KnockRequestsListEvents
} }

View file

@ -11,70 +11,109 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import io.element.android.features.knockrequests.impl.data.KnockRequestsService
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.matrix.api.room.MatrixRoom 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.canBanAsState
import io.element.android.libraries.matrix.ui.room.canInviteAsState import io.element.android.libraries.matrix.ui.room.canInviteAsState
import io.element.android.libraries.matrix.ui.room.canKickAsState import io.element.android.libraries.matrix.ui.room.canKickAsState
import kotlinx.collections.immutable.persistentListOf
import javax.inject.Inject import javax.inject.Inject
class KnockRequestsListPresenter @Inject constructor( class KnockRequestsListPresenter @Inject constructor(
private val room: MatrixRoom, private val room: MatrixRoom,
private val knockRequestsService: KnockRequestsService,
) : Presenter<KnockRequestsListState> { ) : Presenter<KnockRequestsListState> {
@Composable @Composable
override fun present(): KnockRequestsListState { override fun present(): KnockRequestsListState {
val currentAction = remember { mutableStateOf<KnockRequestsCurrentAction>(KnockRequestsCurrentAction.None) } val asyncAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
var actionTarget by remember { mutableStateOf<KnockRequestsActionTarget>(KnockRequestsActionTarget.None) }
var targetActionConfirmed by remember { mutableStateOf(false) }
var retryCount by remember { mutableIntStateOf(0) }
val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val canBan by room.canBanAsState(syncUpdateFlow.value) val canBan by room.canBanAsState(syncUpdateFlow.value)
val canDecline by room.canKickAsState(syncUpdateFlow.value) val canDecline by room.canKickAsState(syncUpdateFlow.value)
val canAccept by room.canInviteAsState(syncUpdateFlow.value) val canAccept by room.canInviteAsState(syncUpdateFlow.value)
val knockRequests by knockRequestsService.knockRequestsFlow.collectAsState()
fun handleEvents(event: KnockRequestsListEvents) { fun handleEvents(event: KnockRequestsListEvents) {
when (event) { when (event) {
KnockRequestsListEvents.AcceptAll -> { KnockRequestsListEvents.AcceptAll -> {
currentAction.value = KnockRequestsCurrentAction.AcceptAll(AsyncAction.Uninitialized) actionTarget = KnockRequestsActionTarget.AcceptAll
} }
is KnockRequestsListEvents.Accept -> { is KnockRequestsListEvents.Accept -> {
currentAction.value = KnockRequestsCurrentAction.Accept(event.knockRequest, AsyncAction.Uninitialized) actionTarget = KnockRequestsActionTarget.Accept(event.knockRequest)
} }
is KnockRequestsListEvents.Decline -> { is KnockRequestsListEvents.Decline -> {
currentAction.value = KnockRequestsCurrentAction.Decline(event.knockRequest, AsyncAction.Uninitialized) actionTarget = KnockRequestsActionTarget.Decline(event.knockRequest)
} }
is KnockRequestsListEvents.DeclineAndBan -> { is KnockRequestsListEvents.DeclineAndBan -> {
currentAction.value = KnockRequestsCurrentAction.DeclineAndBan(event.knockRequest, AsyncAction.Uninitialized) actionTarget = KnockRequestsActionTarget.DeclineAndBan(event.knockRequest)
} }
KnockRequestsListEvents.DismissCurrentAction -> { KnockRequestsListEvents.ResetCurrentAction -> {
currentAction.value = KnockRequestsCurrentAction.None actionTarget = KnockRequestsActionTarget.None
}
KnockRequestsListEvents.RetryCurrentAction -> {
retryCount++
}
KnockRequestsListEvents.ConfirmCurrentAction -> {
targetActionConfirmed = true
} }
} }
} }
LaunchedEffect(currentAction) { LaunchedEffect(actionTarget, targetActionConfirmed, retryCount) {
when (currentAction.value) { when (val action = actionTarget) {
is KnockRequestsCurrentAction.Accept -> { is KnockRequestsActionTarget.Accept -> {
// Accept the knock request runUpdatingState(asyncAction) {
knockRequestsService.acceptKnockRequest(action.knockRequest)
}
} }
is KnockRequestsCurrentAction.Decline -> { is KnockRequestsActionTarget.Decline -> {
// Decline the knock request if (targetActionConfirmed) {
runUpdatingState(asyncAction) {
knockRequestsService.declineKnockRequest(action.knockRequest)
}
} else {
asyncAction.value = AsyncAction.ConfirmingNoParams
}
} }
is KnockRequestsCurrentAction.DeclineAndBan -> { is KnockRequestsActionTarget.DeclineAndBan -> {
// Decline and ban the user if (targetActionConfirmed) {
runUpdatingState(asyncAction) {
knockRequestsService.declineAndBanKnockRequest(action.knockRequest)
}
} else {
asyncAction.value = AsyncAction.ConfirmingNoParams
}
} }
is KnockRequestsCurrentAction.AcceptAll -> { is KnockRequestsActionTarget.AcceptAll -> {
// Accept all knock requests if (targetActionConfirmed) {
runUpdatingState(asyncAction) {
knockRequestsService.acceptAllKnockRequests()
}
} else {
asyncAction.value = AsyncAction.ConfirmingNoParams
}
}
KnockRequestsActionTarget.None -> {
targetActionConfirmed = false
asyncAction.value = AsyncAction.Uninitialized
} }
KnockRequestsCurrentAction.None -> Unit
} }
} }
return KnockRequestsListState( return KnockRequestsListState(
knockRequests = AsyncData.Success(persistentListOf()), knockRequests = knockRequests,
currentAction = currentAction.value, actionTarget = actionTarget,
asyncAction = asyncAction.value,
canAccept = canAccept, canAccept = canAccept,
canDecline = canDecline, canDecline = canDecline,
canBan = canBan, canBan = canBan,

View file

@ -8,27 +8,28 @@
package io.element.android.features.knockrequests.impl.list package io.element.android.features.knockrequests.impl.list
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.features.knockrequests.impl.KnockRequest import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class KnockRequestsListState( data class KnockRequestsListState(
val knockRequests: AsyncData<ImmutableList<KnockRequest>>, val knockRequests: AsyncData<ImmutableList<KnockRequestPresentable>>,
val currentAction: KnockRequestsCurrentAction, val actionTarget: KnockRequestsActionTarget,
val asyncAction: AsyncAction<Unit>,
val canAccept: Boolean, val canAccept: Boolean,
val canDecline: Boolean, val canDecline: Boolean,
val canBan: Boolean, val canBan: Boolean,
val eventSink: (KnockRequestsListEvents) -> Unit, 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 @Immutable
sealed interface KnockRequestsCurrentAction { sealed interface KnockRequestsActionTarget {
data object None : KnockRequestsCurrentAction data object None : KnockRequestsActionTarget
data class Accept(val knockRequest: KnockRequest, val async: AsyncAction<Unit>) : KnockRequestsCurrentAction data class Accept(val knockRequest: KnockRequestPresentable) : KnockRequestsActionTarget
data class Decline(val knockRequest: KnockRequest, val async: AsyncAction<Unit>) : KnockRequestsCurrentAction data class Decline(val knockRequest: KnockRequestPresentable) : KnockRequestsActionTarget
data class DeclineAndBan(val knockRequest: KnockRequest, val async: AsyncAction<Unit>) : KnockRequestsCurrentAction data class DeclineAndBan(val knockRequest: KnockRequestPresentable) : KnockRequestsActionTarget
data class AcceptAll(val async: AsyncAction<Unit>) : KnockRequestsCurrentAction data object AcceptAll : KnockRequestsActionTarget
} }

View file

@ -8,8 +8,8 @@
package io.element.android.features.knockrequests.impl.list package io.element.android.features.knockrequests.impl.list
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.knockrequests.impl.KnockRequest import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
import io.element.android.features.knockrequests.impl.aKnockRequest import io.element.android.features.knockrequests.impl.data.aKnockRequest
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@ -64,7 +64,17 @@ open class KnockRequestsListStateProvider : PreviewParameterProvider<KnockReques
aKnockRequest() aKnockRequest()
) )
), ),
currentAction = KnockRequestsCurrentAction.AcceptAll(AsyncAction.Loading), actionTarget = KnockRequestsActionTarget.AcceptAll,
asyncAction = AsyncAction.ConfirmingNoParams,
),
aKnockRequestsListState(
knockRequests = AsyncData.Success(
persistentListOf(
aKnockRequest()
)
),
actionTarget = KnockRequestsActionTarget.AcceptAll,
asyncAction = AsyncAction.Loading,
), ),
aKnockRequestsListState( aKnockRequestsListState(
knockRequests = AsyncData.Success( knockRequests = AsyncData.Success(
@ -73,6 +83,8 @@ open class KnockRequestsListStateProvider : PreviewParameterProvider<KnockReques
) )
), ),
canAccept = false, canAccept = false,
actionTarget = KnockRequestsActionTarget.AcceptAll,
asyncAction = AsyncAction.Failure(Throwable("Failed to accept all")),
), ),
aKnockRequestsListState( aKnockRequestsListState(
knockRequests = AsyncData.Success( knockRequests = AsyncData.Success(
@ -103,15 +115,17 @@ open class KnockRequestsListStateProvider : PreviewParameterProvider<KnockReques
} }
fun aKnockRequestsListState( fun aKnockRequestsListState(
knockRequests: AsyncData<ImmutableList<KnockRequest>> = AsyncData.Success(persistentListOf()), knockRequests: AsyncData<ImmutableList<KnockRequestPresentable>> = AsyncData.Success(persistentListOf()),
currentAction: KnockRequestsCurrentAction = KnockRequestsCurrentAction.None, actionTarget: KnockRequestsActionTarget = KnockRequestsActionTarget.None,
asyncAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
canAccept: Boolean = true, canAccept: Boolean = true,
canDecline: Boolean = true, canDecline: Boolean = true,
canBan: Boolean = true, canBan: Boolean = true,
eventSink: (KnockRequestsListEvents) -> Unit = {}, eventSink: (KnockRequestsListEvents) -> Unit = {},
) = KnockRequestsListState( ) = KnockRequestsListState(
knockRequests = knockRequests, knockRequests = knockRequests,
currentAction = currentAction, actionTarget = actionTarget,
asyncAction = asyncAction,
canAccept = canAccept, canAccept = canAccept,
canDecline = canDecline, canDecline = canDecline,
canBan = canBan, canBan = canBan,

View file

@ -46,23 +46,25 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.knockrequests.impl.KnockRequest
import io.element.android.features.knockrequests.impl.R import io.element.android.features.knockrequests.impl.R
import io.element.android.features.knockrequests.impl.getAvatarData import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
import io.element.android.features.knockrequests.impl.getBestName import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.aliasScreenTitle import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.OutlinedButton import io.element.android.libraries.designsystem.theme.components.OutlinedButton
@ -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.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar 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 io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -100,14 +103,18 @@ private fun KnockRequestsListContent(
state: KnockRequestsListState, state: KnockRequestsListState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun onAcceptClick(knockRequest: KnockRequest) { fun onAcceptClick(knockRequest: KnockRequestPresentable) {
state.eventSink(KnockRequestsListEvents.Accept(knockRequest)) state.eventSink(KnockRequestsListEvents.Accept(knockRequest))
} }
fun onDeclineClick(knockRequest: KnockRequest) { fun onDeclineClick(knockRequest: KnockRequestPresentable) {
state.eventSink(KnockRequestsListEvents.Decline(knockRequest)) state.eventSink(KnockRequestsListEvents.Decline(knockRequest))
} }
fun onBanClick(knockRequest: KnockRequestPresentable) {
state.eventSink(KnockRequestsListEvents.DeclineAndBan(knockRequest))
}
var bottomPaddingInPixels by remember { mutableIntStateOf(0) } var bottomPaddingInPixels by remember { mutableIntStateOf(0) }
Box(modifier.fillMaxSize()) { Box(modifier.fillMaxSize()) {
@ -124,16 +131,30 @@ private fun KnockRequestsListContent(
canBan = state.canBan, canBan = state.canBan,
onAcceptClick = ::onAcceptClick, onAcceptClick = ::onAcceptClick,
onDeclineClick = ::onDeclineClick, onDeclineClick = ::onDeclineClick,
onBanClick = ::onBanClick,
contentPadding = PaddingValues(bottom = bottomPaddingInPixels.toDp()), contentPadding = PaddingValues(bottom = bottomPaddingInPixels.toDp()),
) )
} }
} }
is AsyncData.Loading -> {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center),
color = ElementTheme.colors.iconPrimary,
)
}
else -> Unit else -> Unit
} }
KnockRequestsActionsView( KnockRequestsActionsView(
actions = state.currentAction, actionTarget = state.actionTarget,
asyncAction = state.asyncAction,
onConfirm = {
state.eventSink(KnockRequestsListEvents.ConfirmCurrentAction)
},
onRetry = {
state.eventSink(KnockRequestsListEvents.RetryCurrentAction)
},
onDismiss = { onDismiss = {
state.eventSink(KnockRequestsListEvents.DismissCurrentAction) state.eventSink(KnockRequestsListEvents.ResetCurrentAction)
}, },
) )
if (state.canAcceptAll) { if (state.canAcceptAll) {
@ -152,53 +173,45 @@ private fun KnockRequestsListContent(
@Composable @Composable
private fun KnockRequestsActionsView( private fun KnockRequestsActionsView(
actions: KnockRequestsCurrentAction, actionTarget: KnockRequestsActionTarget,
asyncAction: AsyncAction<Unit>,
onConfirm: () -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onRetry: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Box(modifier) { Box(modifier) {
when (actions) { AsyncActionView(
is KnockRequestsCurrentAction.AcceptAll -> { async = asyncAction,
AsyncActionView( onSuccess = { onDismiss() },
async = actions.async, onErrorDismiss = onDismiss,
onSuccess = {}, confirmationDialog = {
onErrorDismiss = onDismiss, ConfirmationDialog(
title = "Confirmation",
content = "Are you sure?",
onSubmitClick = onConfirm,
onDismiss = onDismiss,
) )
} },
is KnockRequestsCurrentAction.Accept -> { progressDialog = {
AsyncActionView( ProgressDialog(
async = actions.async, text = "Loading",
onSuccess = {},
onErrorDismiss = onDismiss,
) )
} },
is KnockRequestsCurrentAction.Decline -> { onRetry = onRetry,
AsyncActionView( )
async = actions.async,
onSuccess = {},
onErrorDismiss = onDismiss,
)
}
is KnockRequestsCurrentAction.DeclineAndBan -> {
AsyncActionView(
async = actions.async,
onSuccess = {},
onErrorDismiss = onDismiss,
)
}
KnockRequestsCurrentAction.None -> Unit
}
} }
} }
@Composable @Composable
private fun KnockRequestsList( private fun KnockRequestsList(
knockRequests: ImmutableList<KnockRequest>, knockRequests: ImmutableList<KnockRequestPresentable>,
canAccept: Boolean, canAccept: Boolean,
canDecline: Boolean, canDecline: Boolean,
canBan: Boolean, canBan: Boolean,
onAcceptClick: (KnockRequest) -> Unit, onAcceptClick: (KnockRequestPresentable) -> Unit,
onDeclineClick: (KnockRequest) -> Unit, onDeclineClick: (KnockRequestPresentable) -> Unit,
onBanClick: (KnockRequestPresentable) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp), contentPadding: PaddingValues = PaddingValues(0.dp),
) { ) {
@ -214,6 +227,7 @@ private fun KnockRequestsList(
canDecline = canDecline, canDecline = canDecline,
canAccept = canAccept, canAccept = canAccept,
onDeclineClick = onDeclineClick, onDeclineClick = onDeclineClick,
onBanClick = onBanClick,
) )
if (index != knockRequests.size - 1) { if (index != knockRequests.size - 1) {
HorizontalDivider() HorizontalDivider()
@ -224,12 +238,13 @@ private fun KnockRequestsList(
@Composable @Composable
private fun KnockRequestItem( private fun KnockRequestItem(
knockRequest: KnockRequest, knockRequest: KnockRequestPresentable,
canAccept: Boolean, canAccept: Boolean,
canDecline: Boolean, canDecline: Boolean,
canBan: Boolean, canBan: Boolean,
onAcceptClick: (KnockRequest) -> Unit, onAcceptClick: (KnockRequestPresentable) -> Unit,
onDeclineClick: (KnockRequest) -> Unit, onDeclineClick: (KnockRequestPresentable) -> Unit,
onBanClick: (KnockRequestPresentable) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Row(
@ -252,10 +267,11 @@ private fun KnockRequestItem(
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
style = ElementTheme.typography.fontBodyLgMedium, style = ElementTheme.typography.fontBodyLgMedium,
) )
if (!knockRequest.formattedDate.isNullOrEmpty()) { val formattedDate = knockRequest.formattedDate
if (!formattedDate.isNullOrEmpty()) {
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = knockRequest.formattedDate, text = formattedDate,
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
style = ElementTheme.typography.fontBodySmRegular, style = ElementTheme.typography.fontBodySmRegular,
) )
@ -272,7 +288,8 @@ private fun KnockRequestItem(
) )
} }
// Reason // Reason
if (!knockRequest.reason.isNullOrBlank()) { val reason = knockRequest.reason
if (!reason.isNullOrBlank()) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
var isExpanded by rememberSaveable(knockRequest.userId) { mutableStateOf(false) } var isExpanded by rememberSaveable(knockRequest.userId) { mutableStateOf(false) }
var isExpandable by rememberSaveable(knockRequest.userId) { mutableStateOf(false) } var isExpandable by rememberSaveable(knockRequest.userId) { mutableStateOf(false) }
@ -283,7 +300,7 @@ private fun KnockRequestItem(
.clickable(enabled = isExpandable) { isExpanded = !isExpanded } .clickable(enabled = isExpandable) { isExpanded = !isExpanded }
) { ) {
Text( Text(
text = knockRequest.reason, text = reason,
style = ElementTheme.typography.fontBodyMdRegular, style = ElementTheme.typography.fontBodyMdRegular,
maxLines = if (isExpanded) Int.MAX_VALUE else 3, maxLines = if (isExpanded) Int.MAX_VALUE else 3,
onTextLayout = { result -> onTextLayout = { result ->
@ -336,7 +353,7 @@ private fun KnockRequestItem(
TextButton( TextButton(
text = stringResource(R.string.screen_knock_requests_list_decline_and_ban_action_title), text = stringResource(R.string.screen_knock_requests_list_decline_and_ban_action_title),
onClick = { onClick = {
onAcceptClick(knockRequest) onBanClick(knockRequest)
}, },
destructive = true, destructive = true,
size = ButtonSize.Small, size = ButtonSize.Small,

View file

@ -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.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import java.io.Closeable import java.io.Closeable
@ -53,6 +54,8 @@ interface MatrixRoom : Closeable {
val activeMemberCount: Long val activeMemberCount: Long
val joinedMemberCount: Long val joinedMemberCount: Long
val roomCoroutineScope: CoroutineScope
val roomInfoFlow: Flow<MatrixRoomInfo> val roomInfoFlow: Flow<MatrixRoomInfo>
val roomTypingMembersFlow: Flow<List<UserId>> val roomTypingMembersFlow: Flow<List<UserId>>
val identityStateChangesFlow: Flow<List<IdentityStateChange>> val identityStateChangesFlow: Flow<List<IdentityStateChange>>

View file

@ -7,14 +7,17 @@
package io.element.android.libraries.matrix.api.room.knock 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 import io.element.android.libraries.matrix.api.core.UserId
interface KnockRequest { interface KnockRequest {
val eventId: EventId
val userId: UserId val userId: UserId
val displayName: String? val displayName: String?
val avatarUrl: String? val avatarUrl: String?
val reason: String? val reason: String?
val timestamp: Long? val timestamp: Long?
val isSeen: Boolean
suspend fun accept(): Result<Unit> suspend fun accept(): Result<Unit>

View file

@ -79,7 +79,7 @@ import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.DateDividerMode import org.matrix.rustcomponents.sdk.DateDividerMode
import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener
import org.matrix.rustcomponents.sdk.JoinRequest 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.RoomInfo
import org.matrix.rustcomponents.sdk.RoomInfoListener import org.matrix.rustcomponents.sdk.RoomInfoListener
import org.matrix.rustcomponents.sdk.RoomListItem import org.matrix.rustcomponents.sdk.RoomListItem
@ -162,7 +162,7 @@ class RustMatrixRoom(
} }
override val knockRequestsFlow: Flow<List<KnockRequest>> = mxCallbackFlow { override val knockRequestsFlow: Flow<List<KnockRequest>> = mxCallbackFlow {
innerRoom.subscribeToJoinRequests(object : RequestsToJoinListener { innerRoom.subscribeToJoinRequests(object : JoinRequestsListener {
override fun call(joinRequests: List<JoinRequest>) { override fun call(joinRequests: List<JoinRequest>) {
val knockRequests = joinRequests.map { RustKnockRequest(it) } val knockRequests = joinRequests.map { RustKnockRequest(it) }
channel.trySend(knockRequests) channel.trySend(knockRequests)
@ -176,7 +176,7 @@ class RustMatrixRoom(
// ...except getMember methods as it could quickly fill the roomDispatcher... // ...except getMember methods as it could quickly fill the roomDispatcher...
private val roomMembersDispatcher = coroutineDispatchers.io.limitedParallelism(8) private val roomMembersDispatcher = coroutineDispatchers.io.limitedParallelism(8)
private val roomCoroutineScope = sessionCoroutineScope.childScope(coroutineDispatchers.main, "RoomScope-$roomId") override val roomCoroutineScope = sessionCoroutineScope.childScope(coroutineDispatchers.main, "RoomScope-$roomId")
private val _syncUpdateFlow = MutableStateFlow(0L) private val _syncUpdateFlow = MutableStateFlow(0L)
private val roomMemberListFetcher = RoomMemberListFetcher(innerRoom, roomMembersDispatcher) private val roomMemberListFetcher = RoomMemberListFetcher(innerRoom, roomMembersDispatcher)

View file

@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.impl.room.knock 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.core.UserId
import io.element.android.libraries.matrix.api.room.knock.KnockRequest import io.element.android.libraries.matrix.api.room.knock.KnockRequest
import org.matrix.rustcomponents.sdk.JoinRequest import org.matrix.rustcomponents.sdk.JoinRequest
@ -14,11 +15,13 @@ import org.matrix.rustcomponents.sdk.JoinRequest
class RustKnockRequest( class RustKnockRequest(
private val inner: JoinRequest, private val inner: JoinRequest,
) : KnockRequest { ) : KnockRequest {
override val eventId: EventId = EventId(inner.eventId)
override val userId: UserId = UserId(inner.userId) override val userId: UserId = UserId(inner.userId)
override val displayName: String? = inner.displayName override val displayName: String? = inner.displayName
override val avatarUrl: String? = inner.avatarUrl override val avatarUrl: String? = inner.avatarUrl
override val reason: String? = inner.reason override val reason: String? = inner.reason
override val timestamp: Long? = inner.timestamp?.toLong() override val timestamp: Long? = inner.timestamp?.toLong()
override val isSeen: Boolean = inner.isSeen
override suspend fun accept(): Result<Unit> = runCatching { override suspend fun accept(): Result<Unit> = runCatching {
inner.actions.accept() inner.actions.accept()

View file

@ -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.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.test.TestScope
import java.io.File import java.io.File
class FakeMatrixRoom( class FakeMatrixRoom(
@ -73,6 +75,7 @@ class FakeMatrixRoom(
override val activeMemberCount: Long = 234L, override val activeMemberCount: Long = 234L,
val notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(), val notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(),
override val liveTimeline: Timeline = FakeTimeline(), override val liveTimeline: Timeline = FakeTimeline(),
override val roomCoroutineScope: CoroutineScope = TestScope(),
private var roomPermalinkResult: () -> Result<String> = { lambdaError() }, private var roomPermalinkResult: () -> Result<String> = { lambdaError() },
private var eventPermalinkResult: (EventId) -> Result<String> = { lambdaError() }, private var eventPermalinkResult: (EventId) -> Result<String> = { lambdaError() },
private val sendCallNotificationIfNeededResult: () -> Result<Unit> = { lambdaError() }, private val sendCallNotificationIfNeededResult: () -> Result<Unit> = { lambdaError() },