diff --git a/features/knockrequests/api/build.gradle.kts b/features/knockrequests/api/build.gradle.kts new file mode 100644 index 0000000000..90bcb6e568 --- /dev/null +++ b/features/knockrequests/api/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.knockrequests.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/features/knockrequests/api/src/main/kotlin/io/element/android/features/knockrequests/api/list/KnockRequestsListEntryPoint.kt b/features/knockrequests/api/src/main/kotlin/io/element/android/features/knockrequests/api/list/KnockRequestsListEntryPoint.kt new file mode 100644 index 0000000000..0215b5cde9 --- /dev/null +++ b/features/knockrequests/api/src/main/kotlin/io/element/android/features/knockrequests/api/list/KnockRequestsListEntryPoint.kt @@ -0,0 +1,12 @@ +/* + * 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.api.list + +import io.element.android.libraries.architecture.SimpleFeatureEntryPoint + +interface KnockRequestsListEntryPoint : SimpleFeatureEntryPoint diff --git a/features/knockrequests/impl/build.gradle.kts b/features/knockrequests/impl/build.gradle.kts new file mode 100644 index 0000000000..83f7132320 --- /dev/null +++ b/features/knockrequests/impl/build.gradle.kts @@ -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. + */ + +import extension.setupAnvil + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.knockrequests.impl" +} + +setupAnvil() + +dependencies { + api(projects.features.knockrequests.api) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.designsystem) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrix.test) +} 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 new file mode 100644 index 0000000000..d9df9a5bc2 --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/KnockRequest.kt @@ -0,0 +1,31 @@ +/* + * 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 +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/DefaultKnockRequestsListEntryPoint.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/DefaultKnockRequestsListEntryPoint.kt new file mode 100644 index 0000000000..c685f1cf37 --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/DefaultKnockRequestsListEntryPoint.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.list + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultKnockRequestsListEntryPoint @Inject constructor() : KnockRequestsListEntryPoint { + override fun createNode(parentNode: Node, buildContext: BuildContext): Node { + return parentNode.createNode(buildContext) + } +} 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 new file mode 100644 index 0000000000..132c137ce2 --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListEvents.kt @@ -0,0 +1,18 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.list + +import io.element.android.features.knockrequests.impl.KnockRequest + +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 object AcceptAll : KnockRequestsListEvents + data object DismissCurrentAction : KnockRequestsListEvents +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListNode.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListNode.kt new file mode 100644 index 0000000000..ce8d602861 --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListNode.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.list + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.di.RoomScope + +@ContributesNode(RoomScope::class) +class KnockRequestsListNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: KnockRequestsListPresenter, +) : Node(buildContext, plugins = plugins) { + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + KnockRequestsListView( + state = state, + onBackClick = ::navigateUp, + modifier = modifier + ) + } +} 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 new file mode 100644 index 0000000000..fcafc5428b --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenter.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.list + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.ui.room.canBanAsState +import io.element.android.libraries.matrix.ui.room.canInviteAsState +import io.element.android.libraries.matrix.ui.room.canKickAsState +import kotlinx.collections.immutable.persistentListOf +import javax.inject.Inject + +class KnockRequestsListPresenter @Inject constructor( + private val room: MatrixRoom, +) : Presenter { + @Composable + override fun present(): KnockRequestsListState { + val currentAction = remember { mutableStateOf(KnockRequestsCurrentAction.None) } + val syncUpdateFlow = room.syncUpdateFlow.collectAsState() + val canBan by room.canBanAsState(syncUpdateFlow.value) + val canDecline by room.canKickAsState(syncUpdateFlow.value) + val canAccept by room.canInviteAsState(syncUpdateFlow.value) + + fun handleEvents(event: KnockRequestsListEvents) { + when (event) { + KnockRequestsListEvents.AcceptAll -> { + currentAction.value = KnockRequestsCurrentAction.AcceptAll(AsyncAction.Uninitialized) + } + is KnockRequestsListEvents.Accept -> { + currentAction.value = KnockRequestsCurrentAction.Accept(event.knockRequest, AsyncAction.Uninitialized) + } + is KnockRequestsListEvents.Decline -> { + currentAction.value = KnockRequestsCurrentAction.Decline(event.knockRequest, AsyncAction.Uninitialized) + } + is KnockRequestsListEvents.DeclineAndBan -> { + currentAction.value = KnockRequestsCurrentAction.DeclineAndBan(event.knockRequest, AsyncAction.Uninitialized) + } + KnockRequestsListEvents.DismissCurrentAction -> { + currentAction.value = KnockRequestsCurrentAction.None + } + } + } + + LaunchedEffect(currentAction) { + when (currentAction.value) { + is KnockRequestsCurrentAction.Accept -> { + // Accept the knock request + } + is KnockRequestsCurrentAction.Decline -> { + // Decline the knock request + } + is KnockRequestsCurrentAction.DeclineAndBan -> { + // Decline and ban the user + } + is KnockRequestsCurrentAction.AcceptAll -> { + // Accept all knock requests + } + KnockRequestsCurrentAction.None -> Unit + } + } + + return KnockRequestsListState( + knockRequests = AsyncData.Success(persistentListOf()), + currentAction = currentAction.value, + canAccept = canAccept, + canDecline = canDecline, + canBan = canBan, + eventSink = ::handleEvents + ) + } +} 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 new file mode 100644 index 0000000000..3ba10e9302 --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.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.list + +import androidx.compose.runtime.Immutable +import io.element.android.features.knockrequests.impl.KnockRequest +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 canAccept: Boolean, + val canDecline: Boolean, + val canBan: Boolean, + val eventSink: (KnockRequestsListEvents) -> Unit, +) { + val canAcceptAll = 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 +} 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 new file mode 100644 index 0000000000..551f6b89b0 --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt @@ -0,0 +1,132 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.list + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.knockrequests.impl.KnockRequest +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.UserId +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +open class KnockRequestsListStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aKnockRequestsListState( + knockRequests = AsyncData.Loading(), + ), + aKnockRequestsListState( + knockRequests = AsyncData.Success( + persistentListOf() + ), + ), + aKnockRequestsListState( + knockRequests = AsyncData.Success( + persistentListOf( + aKnockRequest() + ) + ), + ), + aKnockRequestsListState( + knockRequests = AsyncData.Success( + persistentListOf( + aKnockRequest( + reason = "A very long reason that should probably be truncated, " + + "but could be also expanded so you can see it over the lines, wow," + + "very amazing reason, I know, right, I'm so good at writing reasons." + ) + ) + ), + ), + aKnockRequestsListState( + knockRequests = AsyncData.Success( + persistentListOf( + aKnockRequest(), + aKnockRequest( + userId = UserId("@user:example.com"), + displayName = null, + avatarUrl = null, + reason = null, + ) + ) + ), + ), + aKnockRequestsListState( + knockRequests = AsyncData.Success( + persistentListOf( + aKnockRequest() + ) + ), + currentAction = KnockRequestsCurrentAction.AcceptAll(AsyncAction.Loading), + ), + aKnockRequestsListState( + knockRequests = AsyncData.Success( + persistentListOf( + aKnockRequest() + ) + ), + canAccept = false, + ), + aKnockRequestsListState( + knockRequests = AsyncData.Success( + persistentListOf( + aKnockRequest() + ) + ), + canDecline = false, + ), + aKnockRequestsListState( + knockRequests = AsyncData.Success( + persistentListOf( + aKnockRequest() + ) + ), + canAccept = false, + canDecline = false, + ), + aKnockRequestsListState( + knockRequests = AsyncData.Success( + persistentListOf( + aKnockRequest() + ) + ), + canBan = false, + ), + ) +} + +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, +) + +fun aKnockRequestsListState( + knockRequests: AsyncData> = AsyncData.Success(persistentListOf()), + currentAction: KnockRequestsCurrentAction = KnockRequestsCurrentAction.None, + canAccept: Boolean = true, + canDecline: Boolean = true, + canBan: Boolean = true, + eventSink: (KnockRequestsListEvents) -> Unit = {}, +) = KnockRequestsListState( + knockRequests = knockRequests, + currentAction = currentAction, + canAccept = canAccept, + canDecline = canDecline, + canBan = canBan, + eventSink = eventSink, +) 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 new file mode 100644 index 0000000000..ca289eeef4 --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListView.kt @@ -0,0 +1,414 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.list + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +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.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +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.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.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.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.HorizontalDivider +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.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.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun KnockRequestsListView( + state: KnockRequestsListState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + KnockRequestsListTopBar(onBackClick = onBackClick) + }, + content = { padding -> + KnockRequestsListContent( + state = state, + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding), + ) + } + ) +} + +@Composable +private fun KnockRequestsListContent( + state: KnockRequestsListState, + modifier: Modifier = Modifier, +) { + fun onAcceptClick(knockRequest: KnockRequest) { + state.eventSink(KnockRequestsListEvents.Accept(knockRequest)) + } + + fun onDeclineClick(knockRequest: KnockRequest) { + state.eventSink(KnockRequestsListEvents.Decline(knockRequest)) + } + + var bottomPaddingInPixels by remember { mutableIntStateOf(0) } + + Box(modifier.fillMaxSize()) { + when (state.knockRequests) { + is AsyncData.Success -> { + val knockRequests = state.knockRequests.data + if (knockRequests.isEmpty()) { + KnockRequestsEmptyList() + } else { + KnockRequestsList( + knockRequests = knockRequests, + canAccept = state.canAccept, + canDecline = state.canDecline, + canBan = state.canBan, + onAcceptClick = ::onAcceptClick, + onDeclineClick = ::onDeclineClick, + contentPadding = PaddingValues(bottom = bottomPaddingInPixels.toDp()), + ) + } + } + else -> Unit + } + KnockRequestsActionsView( + actions = state.currentAction, + onDismiss = { + state.eventSink(KnockRequestsListEvents.DismissCurrentAction) + }, + ) + if (state.canAcceptAll) { + KnockRequestsAcceptAll( + onClick = { + state.eventSink(KnockRequestsListEvents.AcceptAll) + }, + onHeightChange = { height -> + bottomPaddingInPixels = height + }, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } + } +} + +@Composable +private fun KnockRequestsActionsView( + actions: KnockRequestsCurrentAction, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier) { + when (actions) { + is KnockRequestsCurrentAction.AcceptAll -> { + AsyncActionView( + async = actions.async, + onSuccess = {}, + onErrorDismiss = onDismiss, + ) + } + is KnockRequestsCurrentAction.Accept -> { + AsyncActionView( + async = actions.async, + onSuccess = {}, + onErrorDismiss = onDismiss, + ) + } + is KnockRequestsCurrentAction.Decline -> { + AsyncActionView( + async = actions.async, + onSuccess = {}, + onErrorDismiss = onDismiss, + ) + } + is KnockRequestsCurrentAction.DeclineAndBan -> { + AsyncActionView( + async = actions.async, + onSuccess = {}, + onErrorDismiss = onDismiss, + ) + } + KnockRequestsCurrentAction.None -> Unit + } + } +} + +@Composable +private fun KnockRequestsList( + knockRequests: ImmutableList, + canAccept: Boolean, + canDecline: Boolean, + canBan: Boolean, + onAcceptClick: (KnockRequest) -> Unit, + onDeclineClick: (KnockRequest) -> Unit, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), +) { + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = contentPadding, + ) { + itemsIndexed(knockRequests) { index, knockRequest -> + KnockRequestItem( + knockRequest = knockRequest, + onAcceptClick = onAcceptClick, + canBan = canBan, + canDecline = canDecline, + canAccept = canAccept, + onDeclineClick = onDeclineClick, + ) + if (index != knockRequests.size - 1) { + HorizontalDivider() + } + } + } +} + +@Composable +private fun KnockRequestItem( + knockRequest: KnockRequest, + canAccept: Boolean, + canDecline: Boolean, + canBan: Boolean, + onAcceptClick: (KnockRequest) -> Unit, + onDeclineClick: (KnockRequest) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Avatar(knockRequest.getAvatarData(AvatarSize.KnockRequestItem)) + Spacer(modifier = Modifier.width(16.dp)) + Column { + // Name and date + Row { + Text( + modifier = Modifier + .clipToBounds() + .weight(1f), + text = knockRequest.getBestName(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.primary, + style = ElementTheme.typography.fontBodyLgMedium, + ) + if (!knockRequest.formattedDate.isNullOrEmpty()) { + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = knockRequest.formattedDate, + color = MaterialTheme.colorScheme.secondary, + style = ElementTheme.typography.fontBodySmRegular, + ) + } + } + // UserId + if (!knockRequest.displayName.isNullOrEmpty()) { + Text( + text = knockRequest.userId.value, + color = MaterialTheme.colorScheme.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyMdRegular, + ) + } + // Reason + if (!knockRequest.reason.isNullOrBlank()) { + Spacer(modifier = Modifier.height(12.dp)) + var isExpanded by rememberSaveable(knockRequest.userId) { mutableStateOf(false) } + var isExpandable by rememberSaveable(knockRequest.userId) { mutableStateOf(false) } + Row( + verticalAlignment = Alignment.Top, + modifier = Modifier + .animateContentSize() + .clickable(enabled = isExpandable) { isExpanded = !isExpanded } + ) { + Text( + text = knockRequest.reason, + style = ElementTheme.typography.fontBodyMdRegular, + maxLines = if (isExpanded) Int.MAX_VALUE else 3, + onTextLayout = { result -> + if (!isExpanded && result.hasVisualOverflow) { + isExpandable = true + } + }, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Box(modifier = Modifier.size(24.dp)) { + if (isExpandable) { + Icon( + imageVector = if (isExpanded) CompoundIcons.ChevronUp() else CompoundIcons.ChevronDown(), + contentDescription = null, + tint = ElementTheme.colors.iconTertiary, + ) + } + } + } + } + // Actions + if (canDecline || canAccept) { + Spacer(modifier = Modifier.height(12.dp)) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) { + if (canDecline) { + OutlinedButton( + text = stringResource(CommonStrings.action_decline), + onClick = { + onDeclineClick(knockRequest) + }, + size = ButtonSize.MediumLowPadding, + modifier = Modifier.weight(1f), + ) + } + if (canAccept) { + Button( + text = stringResource(CommonStrings.action_accept), + onClick = { + onAcceptClick(knockRequest) + }, + size = ButtonSize.MediumLowPadding, + modifier = Modifier.weight(1f), + ) + } + } + if (canBan) { + Spacer(modifier = Modifier.height(12.dp)) + TextButton( + text = stringResource(R.string.screen_knock_requests_list_decline_and_ban_action_title), + onClick = { + onAcceptClick(knockRequest) + }, + destructive = true, + size = ButtonSize.Small, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} + +@Composable +private fun KnockRequestsAcceptAll( + onClick: () -> Unit, + onHeightChange: (Int) -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .shadow(elevation = 24.dp, spotColor = Color.Transparent) + .background(color = ElementTheme.colors.bgCanvasDefault) + .padding(vertical = 12.dp, horizontal = 16.dp) + .onSizeChanged { onHeightChange(it.height) } + ) { + OutlinedButton( + text = stringResource(R.string.screen_knock_requests_list_accept_all_button_title), + onClick = onClick, + size = ButtonSize.Medium, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun KnockRequestsEmptyList( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.padding( + horizontal = 32.dp, + vertical = 48.dp, + ), + contentAlignment = Alignment.Center, + ) { + IconTitleSubtitleMolecule( + title = stringResource(R.string.screen_knock_requests_list_empty_state_title), + subTitle = stringResource(R.string.screen_knock_requests_list_empty_state_description), + iconStyle = BigIcon.Style.Default(CompoundIcons.Pin()), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun KnockRequestsListTopBar(onBackClick: () -> Unit) { + TopAppBar( + title = { + Text( + text = stringResource(R.string.screen_knock_requests_list_title), + style = ElementTheme.typography.aliasScreenTitle, + ) + }, + navigationIcon = { BackButton(onClick = onBackClick) }, + ) +} + +@PreviewsDayNight +@Composable +internal fun KnockRequestsListViewPreview( + @PreviewParameter(KnockRequestsListStateProvider::class) state: KnockRequestsListState +) = ElementPreview { + KnockRequestsListView( + state = state, + onBackClick = {}, + ) +} diff --git a/features/knockrequests/impl/src/main/res/values/localazy.xml b/features/knockrequests/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..df14d665b8 --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values/localazy.xml @@ -0,0 +1,17 @@ + + + "Yes, accept all" + "Are you sure you want to accept all requests to join?" + "Accept all requests" + "Accept all" + "Yes, decline and ban" + "Are you sure you want to decline and ban %1$s? This user won’t be able to request access to join this room again." + "Decline and ban from accessing" + "Yes, decline" + "Are you sure you want to decline %1$s request to join this room?" + "Decline access" + "Decline and ban" + "When somebody will ask to join the room, you’ll be able to see their request here." + "No pending request to join" + "Requests to join" + diff --git a/features/roomdetails/impl/build.gradle.kts b/features/roomdetails/impl/build.gradle.kts index 231161e583..bfb2cfde7a 100644 --- a/features/roomdetails/impl/build.gradle.kts +++ b/features/roomdetails/impl/build.gradle.kts @@ -50,6 +50,7 @@ dependencies { implementation(projects.features.poll.api) implementation(projects.features.messages.api) implementation(projects.features.roomcall.api) + implementation(projects.features.knockrequests.api) testImplementation(libs.test.junit) testImplementation(libs.coroutines.test) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt index 12cdbdcadf..eb13a0ba5b 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt @@ -22,6 +22,7 @@ import im.vector.app.features.analytics.plan.Interaction import io.element.android.anvilannotations.ContributesNode import io.element.android.features.call.api.CallType import io.element.android.features.call.api.ElementCallEntryPoint +import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.poll.api.history.PollHistoryEntryPoint import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint @@ -56,6 +57,7 @@ class RoomDetailsFlowNode @AssistedInject constructor( private val room: MatrixRoom, private val analyticsService: AnalyticsService, private val messagesEntryPoint: MessagesEntryPoint, + private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint, private val mediaViewerEntryPoint: MediaViewerEntryPoint, ) : BaseFlowNode( backstack = BackStack( @@ -101,6 +103,9 @@ class RoomDetailsFlowNode @AssistedInject constructor( @Parcelize data object PinnedMessagesList : NavTarget + + @Parcelize + data object KnockRequestsList : NavTarget } override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { @@ -139,6 +144,10 @@ class RoomDetailsFlowNode @AssistedInject constructor( backstack.push(NavTarget.PinnedMessagesList) } + override fun openKnockRequestsList() { + backstack.push(NavTarget.KnockRequestsList) + } + override fun onJoinCall() { val inputs = CallType.RoomCall( sessionId = room.sessionId, @@ -243,6 +252,9 @@ class RoomDetailsFlowNode @AssistedInject constructor( .callback(callback) .build() } + NavTarget.KnockRequestsList -> { + knockRequestsListEntryPoint.createNode(this, buildContext) + } } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt index 19c0b4ffe4..a56a028808 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt @@ -47,6 +47,7 @@ class RoomDetailsNode @AssistedInject constructor( fun openPollHistory() fun openAdminSettings() fun openPinnedMessagesList() + fun openKnockRequestsList() fun onJoinCall() } @@ -111,6 +112,10 @@ class RoomDetailsNode @AssistedInject constructor( callbacks.forEach { it.openPinnedMessagesList() } } + private fun openKnockRequestsLists() { + callbacks.forEach { it.openKnockRequestsList() } + } + @Composable override fun View(modifier: Modifier) { val context = LocalContext.current @@ -140,7 +145,8 @@ class RoomDetailsNode @AssistedInject constructor( openPollHistory = ::openPollHistory, openAdminSettings = this::openAdminSettings, onJoinCallClick = ::onJoinCall, - onPinnedMessagesClick = ::openPinnedMessages + onPinnedMessagesClick = ::openPinnedMessages, + onKnockRequestsClick = ::openKnockRequestsLists, ) } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt index 586790b618..46588ae5fe 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt @@ -38,6 +38,7 @@ import io.element.android.libraries.matrix.api.room.isDm import io.element.android.libraries.matrix.api.room.powerlevels.canInvite import io.element.android.libraries.matrix.api.room.powerlevels.canSendState import io.element.android.libraries.matrix.api.room.roomNotificationSettings +import io.element.android.libraries.matrix.ui.room.canHandleKnockRequestsAsState import io.element.android.libraries.matrix.ui.room.getCurrentRoomMember import io.element.android.libraries.matrix.ui.room.getDirectRoomMember import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin @@ -69,7 +70,7 @@ class RoomDetailsPresenter @Inject constructor( val canShowNotificationSettings = remember { mutableStateOf(false) } val roomInfo by room.roomInfoFlow.collectAsState(initial = null) val isUserAdmin = room.isOwnUserAdmin() - + val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val roomAvatar by remember { derivedStateOf { roomInfo?.avatarUrl ?: room.avatarUrl } } val roomName by remember { derivedStateOf { (roomInfo?.name ?: room.displayName).trim() } } @@ -90,6 +91,7 @@ class RoomDetailsPresenter @Inject constructor( val membersState by room.membersStateFlow.collectAsState() val canInvite by getCanInvite(membersState) + val canEditName by getCanSendState(membersState, StateEventType.ROOM_NAME) val canEditAvatar by getCanSendState(membersState, StateEventType.ROOM_AVATAR) val canEditTopic by getCanSendState(membersState, StateEventType.ROOM_TOPIC) @@ -99,6 +101,8 @@ class RoomDetailsPresenter @Inject constructor( val roomType by getRoomType(dmMember, currentMember) val roomCallState = roomCallStatePresenter.present() + val canHandleKnockRequests by room.canHandleKnockRequestsAsState(syncUpdateFlow.value) + val topicState = remember(canEditTopic, roomTopic, roomType) { val topic = roomTopic @@ -109,6 +113,12 @@ class RoomDetailsPresenter @Inject constructor( } } + val isKnockRequestsEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock).collectAsState(false) + val knockRequestsCount by remember { mutableStateOf(null) } + val canShowKnockRequests by remember { + derivedStateOf { isKnockRequestsEnabled && canHandleKnockRequests } + } + val roomNotificationSettingsState by room.roomNotificationSettingsStateFlow.collectAsState() fun handleEvents(event: RoomDetailsEvent) { @@ -153,6 +163,8 @@ class RoomDetailsPresenter @Inject constructor( heroes = roomInfo?.heroes.orEmpty().toPersistentList(), canShowPinnedMessages = canShowPinnedMessages, pinnedMessagesCount = pinnedMessagesCount, + canShowKnockRequests = canShowKnockRequests, + knockRequestsCount = knockRequestsCount, eventSink = ::handleEvents, ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt index d43b0a813a..7f15c846f9 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt @@ -41,6 +41,8 @@ data class RoomDetailsState( val heroes: ImmutableList, val canShowPinnedMessages: Boolean, val pinnedMessagesCount: Int?, + val canShowKnockRequests: Boolean, + val knockRequestsCount: Int?, val eventSink: (RoomDetailsEvent) -> Unit ) { val roomBadges = buildList { diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt index 49b9f73cb5..dcf5bc3054 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt @@ -102,6 +102,8 @@ fun aRoomDetailsState( heroes: List = emptyList(), canShowPinnedMessages: Boolean = true, pinnedMessagesCount: Int? = null, + canShowKnockRequests: Boolean = false, + knockRequestsCount: Int? = null, eventSink: (RoomDetailsEvent) -> Unit = {}, ) = RoomDetailsState( roomId = roomId, @@ -125,6 +127,8 @@ fun aRoomDetailsState( heroes = heroes.toPersistentList(), canShowPinnedMessages = canShowPinnedMessages, pinnedMessagesCount = pinnedMessagesCount, + canShowKnockRequests = canShowKnockRequests, + knockRequestsCount = knockRequestsCount, eventSink = eventSink ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt index 7e89c3ef07..d77cd9e6ea 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt @@ -104,6 +104,7 @@ fun RoomDetailsView( openAdminSettings: () -> Unit, onJoinCallClick: () -> Unit, onPinnedMessagesClick: () -> Unit, + onKnockRequestsClick: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( @@ -206,6 +207,12 @@ fun RoomDetailsView( memberCount = state.memberCount, openRoomMemberList = openRoomMemberList, ) + if (state.canShowKnockRequests) { + KnockRequestsItem( + knockRequestsCount = state.knockRequestsCount, + onKnockRequestsClick = onKnockRequestsClick + ) + } } } @@ -231,6 +238,20 @@ fun RoomDetailsView( } } +@Composable +private fun KnockRequestsItem(knockRequestsCount: Int?, onKnockRequestsClick: () -> Unit) { + ListItem( + headlineContent = { Text(stringResource(R.string.screen_room_details_requests_to_join_title)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Notifications())), + trailingContent = if (knockRequestsCount == null || knockRequestsCount == 0) { + null + } else { + ListItemContent.Text(knockRequestsCount.toString()) + }, + onClick = onKnockRequestsClick, + ) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun RoomDetailsTopBar( @@ -525,7 +546,7 @@ private fun PinnedMessagesItem( ) { val analyticsService = LocalAnalyticsService.current ListItem( - headlineContent = { Text(stringResource(CommonStrings.screen_room_details_pinned_events_row_title)) }, + headlineContent = { Text(stringResource(R.string.screen_room_details_pinned_events_row_title)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Pin())), trailingContent = if (pinnedMessagesCount == null) { @@ -613,5 +634,6 @@ private fun ContentToPreview(state: RoomDetailsState) { openAdminSettings = {}, onJoinCallClick = {}, onPinnedMessagesClick = {}, + onKnockRequestsClick = {}, ) } diff --git a/features/roomdetails/impl/src/main/res/values/localazy.xml b/features/roomdetails/impl/src/main/res/values/localazy.xml index 19400c85b3..50c2d639be 100644 --- a/features/roomdetails/impl/src/main/res/values/localazy.xml +++ b/features/roomdetails/impl/src/main/res/values/localazy.xml @@ -49,9 +49,12 @@ "Invite people" "Leave conversation" "Leave room" + "Media and files" "Custom" "Default" "Notifications" + "Pinned messages" + "Requests to join" "Roles and permissions" "Room name" "Security" diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt index e4eb8d5f69..abbca71b53 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt @@ -129,7 +129,7 @@ class RoomDetailsViewTest { ), onPinnedMessagesClick = callback, ) - rule.clickOn(CommonStrings.screen_room_details_pinned_events_row_title) + rule.clickOn(R.string.screen_room_details_pinned_events_row_title) } } @@ -253,6 +253,21 @@ class RoomDetailsViewTest { rule.clickOn(R.string.screen_room_details_leave_room_title) eventsRecorder.assertSingle(RoomDetailsEvent.LeaveRoom) } + + @Config(qualifiers = "h1024dp") + @Test + fun `click on knock requests invokes expected callback`() { + ensureCalledOnce { callback -> + rule.setRoomDetailView( + state = aRoomDetailsState( + eventSink = EventsRecorder(expectEvents = false), + canShowKnockRequests = true, + ), + onKnockRequestsClick = callback, + ) + rule.clickOn(R.string.screen_room_details_requests_to_join_title) + } + } } private fun AndroidComposeTestRule.setRoomDetailView( @@ -270,6 +285,7 @@ private fun AndroidComposeTestRule.setRoomD openAdminSettings: () -> Unit = EnsureNeverCalled(), onJoinCallClick: () -> Unit = EnsureNeverCalled(), onPinnedMessagesClick: () -> Unit = EnsureNeverCalled(), + onKnockRequestsClick: () -> Unit = EnsureNeverCalled(), ) { setContent { RoomDetailsView( @@ -285,6 +301,7 @@ private fun AndroidComposeTestRule.setRoomD openAdminSettings = openAdminSettings, onJoinCallClick = onJoinCallClick, onPinnedMessagesClick = onPinnedMessagesClick, + onKnockRequestsClick = onKnockRequestsClick, ) } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt index 49a3e93e87..c5bd468abe 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt @@ -54,4 +54,6 @@ enum class AvatarSize(val dp: Dp) { EditProfileDetails(96.dp), Suggestion(32.dp), + + KnockRequestItem(52.dp), } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomPowerLevels.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomPowerLevels.kt index 682596a59d..ba81a5780c 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomPowerLevels.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomPowerLevels.kt @@ -57,6 +57,13 @@ suspend fun MatrixRoom.canRedactOwn(): Result = canUserRedactOwn(sessio */ suspend fun MatrixRoom.canRedactOther(): Result = canUserRedactOther(sessionId) +/** + * Shortcut for checking if current user can handle knock requests. + */ +suspend fun MatrixRoom.canHandleKnockRequests(): Result = runCatching { + canInvite().getOrThrow() || canBan().getOrThrow() || canKick().getOrThrow() +} + /** * Shortcut for calling [MatrixRoom.canUserPinUnpin] with our own user. */ diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomState.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomState.kt index 81ae3e6b89..59bd1fa773 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomState.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomState.kt @@ -17,6 +17,7 @@ import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.isDm import io.element.android.libraries.matrix.api.room.powerlevels.canBan +import io.element.android.libraries.matrix.api.room.powerlevels.canHandleKnockRequests import io.element.android.libraries.matrix.api.room.powerlevels.canInvite import io.element.android.libraries.matrix.api.room.powerlevels.canKick import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther @@ -86,6 +87,13 @@ fun MatrixRoom.canBanAsState(updateKey: Long): State { } } +@Composable +fun MatrixRoom.canHandleKnockRequestsAsState(updateKey: Long): State { + return produceState(initialValue = false, key1 = updateKey) { + value = canHandleKnockRequests().getOrElse { false } + } +} + @Composable fun MatrixRoom.userPowerLevelAsState(updateKey: Long): State { return produceState(initialValue = 0, key1 = updateKey) { diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 8f4aa8b20c..75b0f6853a 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -299,20 +299,6 @@ Reason: %1$s." "Hey, talk to me on %1$s: %2$s" "%1$s Android" "Rageshake to report bug" - "Yes, accept all" - "Are you sure you want to accept all requests to join?" - "Accept all requests" - "Accept all" - "Yes, decline and ban" - "Are you sure you want to decline and ban %1$s? This user won’t be able to request access to join this room again." - "Decline and ban from accessing" - "Yes, decline" - "Are you sure you want to decline %1$s request to join this room?" - "Decline access" - "Decline and ban" - "When somebody will ask to join the room, you’ll be able to see their request here." - "No pending request to join" - "Requests to join" "Failed selecting media, please try again." "Captions might not be visible to people using older apps." "Failed processing media to upload, please try again." @@ -334,8 +320,6 @@ Reason: %1$s." "Your message was not sent because %1$s has not verified all devices" "One or more of your devices are unverified. You can send the message anyway, or you can cancel for now and try again later after you have verified all of your devices." "Your message was not sent because you have not verified one or more of your devices" - "Pinned messages" - "Requests to join" "Failed processing media to upload, please try again." "Could not retrieve user details" diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_0_en.png new file mode 100644 index 0000000000..d2ddc2c7c6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fbbd687e5e0a1fd3a1be442937e32990dc72a6e4817d6ffc29f6f596eeddef1b +size 8086 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_1_en.png new file mode 100644 index 0000000000..66c6102947 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a9595edf8183796a0a0efa838a3c5265e07f70452933e6255d79f536cfca64ac +size 26138 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_2_en.png new file mode 100644 index 0000000000..b75df7ecea --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a71bcfe210fdc6b2a0c54fe187d9528811bd6b5d442a66a1151b7a00ae514273 +size 33141 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_3_en.png new file mode 100644 index 0000000000..92f98168bb --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:036a4f8f2f478df0e1705ff4bfd04599f6c0251936d2a80d750c9055d9d63ae0 +size 41550 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_4_en.png new file mode 100644 index 0000000000..f125ca034e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:99f070f9b5bdea3aa057776b303be171daff36f684770174f46b37ecc0eea781 +size 52135 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_5_en.png new file mode 100644 index 0000000000..c14ef5dd52 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f7e66d3f7d10e759f629795580545cda6644292020ce36763d07e9d476dcc231 +size 30325 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_6_en.png new file mode 100644 index 0000000000..466e591450 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:586021e67718aa949b1de04799a43d9da8189fadacdb6dba23405c762fc7ee06 +size 30618 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_7_en.png new file mode 100644 index 0000000000..2952c5bd81 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_7_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1b71dc6dc0d29801e81d1d4e52df811624583f69c0510f2f650676747a326455 +size 30296 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_8_en.png new file mode 100644 index 0000000000..9c47af83a4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f6be2cfe7991bce6c622f4f2e45f4ac96810cdc9462d9a4d7aea11baf69ab5ac +size 27446 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_9_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_9_en.png new file mode 100644 index 0000000000..8f0414d4b3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Day_9_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9afb58cfcd13c064f0b17d449318d5111aaa914cb8875fe0596c66e5a8a17051 +size 30647 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_0_en.png new file mode 100644 index 0000000000..5188e40e69 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7ff4bc9e0588ea6a8396572b9ac9fb2401c3193fc3f1fbeb75efd69be6dda24d +size 7867 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_1_en.png new file mode 100644 index 0000000000..055bfccd93 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:37e24295a404463010f6d36ab94b1ac3c2e193d9122637abbeb0a43abcb427c1 +size 25669 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_2_en.png new file mode 100644 index 0000000000..b9982135b9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7548a1029b4e63722aca769800728c0bb519d3d736cb54138bc5e053a1aca46e +size 32150 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_3_en.png new file mode 100644 index 0000000000..2c6855f6ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3b094584e67852da5ecb74511143943d3526965a548a24d5bb4e23540fc77b90 +size 40527 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_4_en.png new file mode 100644 index 0000000000..0bca790524 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:79e34ab35d9bd44d6e7694df882995f401f49a2ba042c6a3b2c010c12657750f +size 50637 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_5_en.png new file mode 100644 index 0000000000..45b2a829ef --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c30b6e25ec148d4f98a6c305b7d71af6c7e413cc3425f5a6f5b42a201039f491 +size 29007 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_6_en.png new file mode 100644 index 0000000000..b39ccb0150 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e5c6abc8bd5ca40eb91d926adc798cee95a8aacb0e87d937983c6240173fe2b9 +size 30071 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_7_en.png new file mode 100644 index 0000000000..215cef276c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_7_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3b67c8d2cedb724ece75a2171463c4e035d562c4480d39b11b1072f4fdae3053 +size 29840 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_8_en.png new file mode 100644 index 0000000000..171b5f5aa5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:15903c9494100becc4dd2bc6e9fb7f38f8eebbb595cbc251cfc0f839d9a99a27 +size 27322 diff --git a/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_9_en.png b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_9_en.png new file mode 100644 index 0000000000..54a5fc7d9c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.knockrequests.impl.list_KnockRequestsListView_Night_9_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:68294db4cbd8bb42f4a0b6534b48585341beb2bc405e93cf74a2655f049522ff +size 29768 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_78_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_78_en.png new file mode 100644 index 0000000000..cf85f766ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_78_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:66c29b560708bb2d8870d8cf5caf4f0da49815b5527fed7294a88c0b3aa05c73 +size 18079 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_79_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_79_en.png new file mode 100644 index 0000000000..b27ebae0ce --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_79_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3dd106bc9b54dfc0c4e3e9a5f04d3df52f58f71e4c3cd3d80f53d3f1308f22a1 +size 16838 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_80_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_80_en.png new file mode 100644 index 0000000000..1698660c8e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_80_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4979794c700bc8bab1bc767cc2387ce3be28b9d2c0b6da0696b237445ce7df95 +size 21225 diff --git a/tools/localazy/config.json b/tools/localazy/config.json index f18744bae9..fe95d1930e 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -165,6 +165,7 @@ "name" : ":features:roomdetails:impl", "includeRegex" : [ "screen_room_details_.*", + "screen\\.room_details\\..*", "screen_room_member_list_.*", "screen_room_notification_settings_.*", "screen_notification_settings_edit_failed_updating_default_mode", @@ -286,6 +287,12 @@ "screen_join_room_.*", "screen\\.join_room\\..*" ] + }, + { + "name" : ":features:knockrequests:impl", + "includeRegex" : [ + "screen\\.knock_requests_list\\..*" + ] } ] }