Merge pull request #3995 from element-hq/feature/fga/requests_to_join_list

feat(knock_requests_list) : implement design
This commit is contained in:
ganfra
2024-12-06 13:28:52 +01:00
committed by GitHub
49 changed files with 1031 additions and 20 deletions

View File

@@ -0,0 +1,19 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.knockrequests.api"
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
}

View File

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

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.
*/
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)
}

View File

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

View File

@@ -0,0 +1,23 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.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<KnockRequestsListNode>(buildContext)
}
}

View File

@@ -0,0 +1,18 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.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
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.knockrequests.impl.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<Plugin>,
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
)
}
}

View File

@@ -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<KnockRequestsListState> {
@Composable
override fun present(): KnockRequestsListState {
val currentAction = remember { mutableStateOf<KnockRequestsCurrentAction>(KnockRequestsCurrentAction.None) }
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val canBan by room.canBanAsState(syncUpdateFlow.value)
val canDecline by room.canKickAsState(syncUpdateFlow.value)
val canAccept by room.canInviteAsState(syncUpdateFlow.value)
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
)
}
}

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.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<ImmutableList<KnockRequest>>,
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<Unit>) : KnockRequestsCurrentAction
data class Decline(val knockRequest: KnockRequest, val async: AsyncAction<Unit>) : KnockRequestsCurrentAction
data class DeclineAndBan(val knockRequest: KnockRequest, val async: AsyncAction<Unit>) : KnockRequestsCurrentAction
data class AcceptAll(val async: AsyncAction<Unit>) : KnockRequestsCurrentAction
}

View File

@@ -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<KnockRequestsListState> {
override val values: Sequence<KnockRequestsListState>
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<ImmutableList<KnockRequest>> = 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,
)

View File

@@ -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<KnockRequest>,
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 = {},
)
}

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_knock_requests_list_accept_all_alert_confirm_button_title">"Yes, accept all"</string>
<string name="screen_knock_requests_list_accept_all_alert_description">"Are you sure you want to accept all requests to join?"</string>
<string name="screen_knock_requests_list_accept_all_alert_title">"Accept all requests"</string>
<string name="screen_knock_requests_list_accept_all_button_title">"Accept all"</string>
<string name="screen_knock_requests_list_ban_alert_confirm_button_title">"Yes, decline and ban"</string>
<string name="screen_knock_requests_list_ban_alert_description">"Are you sure you want to decline and ban %1$s? This user wont be able to request access to join this room again."</string>
<string name="screen_knock_requests_list_ban_alert_title">"Decline and ban from accessing"</string>
<string name="screen_knock_requests_list_decline_alert_confirm_button_title">"Yes, decline"</string>
<string name="screen_knock_requests_list_decline_alert_description">"Are you sure you want to decline %1$s request to join this room?"</string>
<string name="screen_knock_requests_list_decline_alert_title">"Decline access"</string>
<string name="screen_knock_requests_list_decline_and_ban_action_title">"Decline and ban"</string>
<string name="screen_knock_requests_list_empty_state_description">"When somebody will ask to join the room, youll be able to see their request here."</string>
<string name="screen_knock_requests_list_empty_state_title">"No pending request to join"</string>
<string name="screen_knock_requests_list_title">"Requests to join"</string>
</resources>

View File

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

View File

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

View File

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

View File

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

View File

@@ -41,6 +41,8 @@ data class RoomDetailsState(
val heroes: ImmutableList<MatrixUser>,
val canShowPinnedMessages: Boolean,
val pinnedMessagesCount: Int?,
val canShowKnockRequests: Boolean,
val knockRequestsCount: Int?,
val eventSink: (RoomDetailsEvent) -> Unit
) {
val roomBadges = buildList {

View File

@@ -102,6 +102,8 @@ fun aRoomDetailsState(
heroes: List<MatrixUser> = 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
)

View File

@@ -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 = {},
)
}

View File

@@ -49,9 +49,12 @@
<string name="screen_room_details_invite_people_title">"Invite people"</string>
<string name="screen_room_details_leave_conversation_title">"Leave conversation"</string>
<string name="screen_room_details_leave_room_title">"Leave room"</string>
<string name="screen_room_details_media_gallery_title">"Media and files"</string>
<string name="screen_room_details_notification_mode_custom">"Custom"</string>
<string name="screen_room_details_notification_mode_default">"Default"</string>
<string name="screen_room_details_notification_title">"Notifications"</string>
<string name="screen_room_details_pinned_events_row_title">"Pinned messages"</string>
<string name="screen_room_details_requests_to_join_title">"Requests to join"</string>
<string name="screen_room_details_roles_and_permissions">"Roles and permissions"</string>
<string name="screen_room_details_room_name_label">"Room name"</string>
<string name="screen_room_details_security_title">"Security"</string>

View File

@@ -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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomDetailView(
@@ -270,6 +285,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomD
openAdminSettings: () -> Unit = EnsureNeverCalled(),
onJoinCallClick: () -> Unit = EnsureNeverCalled(),
onPinnedMessagesClick: () -> Unit = EnsureNeverCalled(),
onKnockRequestsClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
RoomDetailsView(
@@ -285,6 +301,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomD
openAdminSettings = openAdminSettings,
onJoinCallClick = onJoinCallClick,
onPinnedMessagesClick = onPinnedMessagesClick,
onKnockRequestsClick = onKnockRequestsClick,
)
}
}

View File

@@ -54,4 +54,6 @@ enum class AvatarSize(val dp: Dp) {
EditProfileDetails(96.dp),
Suggestion(32.dp),
KnockRequestItem(52.dp),
}

View File

@@ -57,6 +57,13 @@ suspend fun MatrixRoom.canRedactOwn(): Result<Boolean> = canUserRedactOwn(sessio
*/
suspend fun MatrixRoom.canRedactOther(): Result<Boolean> = canUserRedactOther(sessionId)
/**
* Shortcut for checking if current user can handle knock requests.
*/
suspend fun MatrixRoom.canHandleKnockRequests(): Result<Boolean> = runCatching {
canInvite().getOrThrow() || canBan().getOrThrow() || canKick().getOrThrow()
}
/**
* Shortcut for calling [MatrixRoom.canUserPinUnpin] with our own user.
*/

View File

@@ -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<Boolean> {
}
}
@Composable
fun MatrixRoom.canHandleKnockRequestsAsState(updateKey: Long): State<Boolean> {
return produceState(initialValue = false, key1 = updateKey) {
value = canHandleKnockRequests().getOrElse { false }
}
}
@Composable
fun MatrixRoom.userPowerLevelAsState(updateKey: Long): State<Long> {
return produceState(initialValue = 0, key1 = updateKey) {

View File

@@ -299,20 +299,6 @@ Reason: %1$s."</string>
<string name="invite_friends_text">"Hey, talk to me on %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Rageshake to report bug"</string>
<string name="screen_knock_requests_list_accept_all_alert_confirm_button_title">"Yes, accept all"</string>
<string name="screen_knock_requests_list_accept_all_alert_description">"Are you sure you want to accept all requests to join?"</string>
<string name="screen_knock_requests_list_accept_all_alert_title">"Accept all requests"</string>
<string name="screen_knock_requests_list_accept_all_button_title">"Accept all"</string>
<string name="screen_knock_requests_list_ban_alert_confirm_button_title">"Yes, decline and ban"</string>
<string name="screen_knock_requests_list_ban_alert_description">"Are you sure you want to decline and ban %1$s? This user wont be able to request access to join this room again."</string>
<string name="screen_knock_requests_list_ban_alert_title">"Decline and ban from accessing"</string>
<string name="screen_knock_requests_list_decline_alert_confirm_button_title">"Yes, decline"</string>
<string name="screen_knock_requests_list_decline_alert_description">"Are you sure you want to decline %1$s request to join this room?"</string>
<string name="screen_knock_requests_list_decline_alert_title">"Decline access"</string>
<string name="screen_knock_requests_list_decline_and_ban_action_title">"Decline and ban"</string>
<string name="screen_knock_requests_list_empty_state_description">"When somebody will ask to join the room, youll be able to see their request here."</string>
<string name="screen_knock_requests_list_empty_state_title">"No pending request to join"</string>
<string name="screen_knock_requests_list_title">"Requests to join"</string>
<string name="screen_media_picker_error_failed_selection">"Failed selecting media, please try again."</string>
<string name="screen_media_upload_preview_caption_warning">"Captions might not be visible to people using older apps."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Failed processing media to upload, please try again."</string>
@@ -334,8 +320,6 @@ Reason: %1$s."</string>
<string name="screen_resolve_send_failure_unsigned_device_title">"Your message was not sent because %1$s has not verified all devices"</string>
<string name="screen_resolve_send_failure_you_unsigned_device_subtitle">"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."</string>
<string name="screen_resolve_send_failure_you_unsigned_device_title">"Your message was not sent because you have not verified one or more of your devices"</string>
<string name="screen_room_details_pinned_events_row_title">"Pinned messages"</string>
<string name="screen_room_details_requests_to_join_title">"Requests to join"</string>
<string name="screen_room_error_failed_processing_media">"Failed processing media to upload, please try again."</string>
<string name="screen_room_error_failed_retrieving_user_details">"Could not retrieve user details"</string>
<plurals name="screen_room_multiple_knock_requests_title">

View File

@@ -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\\..*"
]
}
]
}