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
commit eae73ac2b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fbbd687e5e0a1fd3a1be442937e32990dc72a6e4817d6ffc29f6f596eeddef1b
size 8086

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a9595edf8183796a0a0efa838a3c5265e07f70452933e6255d79f536cfca64ac
size 26138

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a71bcfe210fdc6b2a0c54fe187d9528811bd6b5d442a66a1151b7a00ae514273
size 33141

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:036a4f8f2f478df0e1705ff4bfd04599f6c0251936d2a80d750c9055d9d63ae0
size 41550

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:99f070f9b5bdea3aa057776b303be171daff36f684770174f46b37ecc0eea781
size 52135

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f7e66d3f7d10e759f629795580545cda6644292020ce36763d07e9d476dcc231
size 30325

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:586021e67718aa949b1de04799a43d9da8189fadacdb6dba23405c762fc7ee06
size 30618

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1b71dc6dc0d29801e81d1d4e52df811624583f69c0510f2f650676747a326455
size 30296

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f6be2cfe7991bce6c622f4f2e45f4ac96810cdc9462d9a4d7aea11baf69ab5ac
size 27446

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9afb58cfcd13c064f0b17d449318d5111aaa914cb8875fe0596c66e5a8a17051
size 30647

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7ff4bc9e0588ea6a8396572b9ac9fb2401c3193fc3f1fbeb75efd69be6dda24d
size 7867

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:37e24295a404463010f6d36ab94b1ac3c2e193d9122637abbeb0a43abcb427c1
size 25669

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7548a1029b4e63722aca769800728c0bb519d3d736cb54138bc5e053a1aca46e
size 32150

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3b094584e67852da5ecb74511143943d3526965a548a24d5bb4e23540fc77b90
size 40527

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:79e34ab35d9bd44d6e7694df882995f401f49a2ba042c6a3b2c010c12657750f
size 50637

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c30b6e25ec148d4f98a6c305b7d71af6c7e413cc3425f5a6f5b42a201039f491
size 29007

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e5c6abc8bd5ca40eb91d926adc798cee95a8aacb0e87d937983c6240173fe2b9
size 30071

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3b67c8d2cedb724ece75a2171463c4e035d562c4480d39b11b1072f4fdae3053
size 29840

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:15903c9494100becc4dd2bc6e9fb7f38f8eebbb595cbc251cfc0f839d9a99a27
size 27322

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:68294db4cbd8bb42f4a0b6534b48585341beb2bc405e93cf74a2655f049522ff
size 29768

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:66c29b560708bb2d8870d8cf5caf4f0da49815b5527fed7294a88c0b3aa05c73
size 18079

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3dd106bc9b54dfc0c4e3e9a5f04d3df52f58f71e4c3cd3d80f53d3f1308f22a1
size 16838

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4979794c700bc8bab1bc767cc2387ce3be28b9d2c0b6da0696b237445ce7df95
size 21225

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