Merge pull request #4005 from element-hq/feature/fga/requests_to_join_banner
feat(knock) : Knock Requests Banner UI
This commit is contained in:
commit
9dac27a165
42 changed files with 581 additions and 47 deletions
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
id("io.element.android-compose-library")
|
||||
}
|
||||
|
||||
android {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* 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.banner
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
interface KnockRequestsBannerRenderer {
|
||||
@Composable
|
||||
fun View(modifier: Modifier, onViewRequestsClick: () -> Unit)
|
||||
}
|
||||
|
|
@ -29,3 +29,17 @@ fun KnockRequest.getAvatarData(size: AvatarSize) = AvatarData(
|
|||
fun KnockRequest.getBestName(): String {
|
||||
return displayName?.takeIf { it.isNotEmpty() } ?: userId.value
|
||||
}
|
||||
|
||||
fun aKnockRequest(
|
||||
userId: UserId = UserId("@jacob_ross:example.com"),
|
||||
displayName: String? = "Jacob Ross",
|
||||
avatarUrl: String? = null,
|
||||
reason: String? = "Hi, I would like to get access to this room please.",
|
||||
formattedDate: String = "20 Nov 2024",
|
||||
) = KnockRequest(
|
||||
userId = userId,
|
||||
displayName = displayName,
|
||||
avatarUrl = avatarUrl,
|
||||
reason = reason,
|
||||
formattedDate = formattedDate,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* 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.banner
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.knockrequests.api.banner.KnockRequestsBannerRenderer
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(RoomScope::class)
|
||||
class DefaultKnockRequestsBannerRenderer @Inject constructor(
|
||||
private val presenter: KnockRequestsBannerPresenter,
|
||||
) : KnockRequestsBannerRenderer {
|
||||
@Composable
|
||||
override fun View(modifier: Modifier, onViewRequestsClick: () -> Unit) {
|
||||
val state = presenter.present()
|
||||
KnockRequestsBannerView(
|
||||
state = state,
|
||||
onViewRequestsClick = onViewRequestsClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* 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.banner
|
||||
|
||||
sealed interface KnockRequestsBannerEvents {
|
||||
data object AcceptSingleRequest : KnockRequestsBannerEvents
|
||||
data object Dismiss : KnockRequestsBannerEvents
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* 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.banner
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import javax.inject.Inject
|
||||
|
||||
class KnockRequestsBannerPresenter @Inject constructor() : Presenter<KnockRequestsBannerState> {
|
||||
@Composable
|
||||
override fun present(): KnockRequestsBannerState {
|
||||
var shouldShowBanner by remember { mutableStateOf(false) }
|
||||
|
||||
fun handleEvents(event: KnockRequestsBannerEvents) {
|
||||
when (event) {
|
||||
is KnockRequestsBannerEvents.AcceptSingleRequest -> Unit
|
||||
is KnockRequestsBannerEvents.Dismiss -> {
|
||||
shouldShowBanner = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return KnockRequestsBannerState(
|
||||
knockRequests = persistentListOf(),
|
||||
acceptAction = AsyncAction.Uninitialized,
|
||||
canAccept = false,
|
||||
isVisible = shouldShowBanner,
|
||||
eventSink = ::handleEvents,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* 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.banner
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.features.knockrequests.impl.KnockRequest
|
||||
import io.element.android.features.knockrequests.impl.R
|
||||
import io.element.android.features.knockrequests.impl.getBestName
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.core.extensions.firstIfSingle
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class KnockRequestsBannerState(
|
||||
val isVisible: Boolean,
|
||||
val knockRequests: ImmutableList<KnockRequest>,
|
||||
val acceptAction: AsyncAction<Unit>,
|
||||
val canAccept: Boolean,
|
||||
val eventSink: (KnockRequestsBannerEvents) -> Unit,
|
||||
) {
|
||||
val subtitle = knockRequests.firstIfSingle()?.userId?.value
|
||||
val reason = knockRequests.firstIfSingle()?.reason
|
||||
|
||||
@Composable
|
||||
fun formattedTitle(): String {
|
||||
return when (knockRequests.size) {
|
||||
0 -> ""
|
||||
1 -> stringResource(R.string.screen_room_single_knock_request_title, knockRequests.first().getBestName())
|
||||
else -> {
|
||||
val firstRequest = knockRequests.first()
|
||||
val otherRequestsCount = knockRequests.size - 1
|
||||
pluralStringResource(
|
||||
id = R.plurals.screen_room_multiple_knock_requests_title,
|
||||
count = otherRequestsCount,
|
||||
firstRequest.getBestName(),
|
||||
otherRequestsCount
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* 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.banner
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.knockrequests.impl.KnockRequest
|
||||
import io.element.android.features.knockrequests.impl.aKnockRequest
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
class KnockRequestsBannerStateProvider : PreviewParameterProvider<KnockRequestsBannerState> {
|
||||
override val values: Sequence<KnockRequestsBannerState>
|
||||
get() = sequenceOf(
|
||||
aKnockRequestsBannerState(),
|
||||
aKnockRequestsBannerState(
|
||||
knockRequests = listOf(
|
||||
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."
|
||||
)
|
||||
)
|
||||
),
|
||||
aKnockRequestsBannerState(
|
||||
knockRequests = listOf(
|
||||
aKnockRequest(),
|
||||
aKnockRequest(displayName = "Alice")
|
||||
)
|
||||
),
|
||||
aKnockRequestsBannerState(
|
||||
knockRequests = listOf(
|
||||
aKnockRequest(),
|
||||
aKnockRequest(displayName = "Alice"),
|
||||
aKnockRequest(displayName = "Bob"),
|
||||
aKnockRequest(displayName = "Charlie")
|
||||
)
|
||||
),
|
||||
aKnockRequestsBannerState(
|
||||
canAccept = false
|
||||
),
|
||||
aKnockRequestsBannerState(
|
||||
acceptAction = AsyncAction.Loading
|
||||
),
|
||||
aKnockRequestsBannerState(
|
||||
acceptAction = AsyncAction.Failure(Throwable("Failed to accept knock"))
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun aKnockRequestsBannerState(
|
||||
knockRequests: List<KnockRequest> = listOf(aKnockRequest()),
|
||||
acceptAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
canAccept: Boolean = true,
|
||||
isVisible: Boolean = true,
|
||||
eventSink: (KnockRequestsBannerEvents) -> Unit = {}
|
||||
) = KnockRequestsBannerState(
|
||||
knockRequests = knockRequests.toImmutableList(),
|
||||
acceptAction = acceptAction,
|
||||
canAccept = canAccept,
|
||||
isVisible = isVisible,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
/*
|
||||
* 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.banner
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
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.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
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.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
private const val MAX_AVATAR_COUNT = 3
|
||||
|
||||
@Composable
|
||||
fun KnockRequestsBannerView(
|
||||
state: KnockRequestsBannerState,
|
||||
onViewRequestsClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = state.isVisible,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = ElementTheme.colors.bgCanvasDefaultLevel1,
|
||||
shadowElevation = 24.dp,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
) {
|
||||
KnockRequestsBannerContent(
|
||||
state = state,
|
||||
onViewRequestsClick = onViewRequestsClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KnockRequestsBannerContent(
|
||||
state: KnockRequestsBannerState,
|
||||
onViewRequestsClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun onDismissClick() {
|
||||
state.eventSink(KnockRequestsBannerEvents.Dismiss)
|
||||
}
|
||||
|
||||
fun onAcceptClick() {
|
||||
state.eventSink(KnockRequestsBannerEvents.AcceptSingleRequest)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(all = 16.dp)
|
||||
) {
|
||||
Row {
|
||||
KnockRequestAvatarView(state.knockRequests)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = state.formattedTitle(),
|
||||
style = ElementTheme.typography.fontBodyMdMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
if (state.subtitle != null) {
|
||||
Text(
|
||||
text = state.subtitle,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
modifier = Modifier.clickable(onClick = ::onDismissClick),
|
||||
imageVector = CompoundIcons.Close(),
|
||||
contentDescription = stringResource(CommonStrings.action_close)
|
||||
)
|
||||
}
|
||||
if (state.reason != null) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = state.reason,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
if (state.knockRequests.size > 1) {
|
||||
Button(
|
||||
text = stringResource(R.string.screen_room_multiple_knock_requests_view_all_button_title),
|
||||
onClick = onViewRequestsClick,
|
||||
size = ButtonSize.MediumLowPadding,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
} else {
|
||||
OutlinedButton(
|
||||
text = stringResource(R.string.screen_room_single_knock_request_view_button_title),
|
||||
onClick = onViewRequestsClick,
|
||||
size = ButtonSize.MediumLowPadding,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (state.canAccept) {
|
||||
Button(
|
||||
text = stringResource(R.string.screen_room_single_knock_request_accept_button_title),
|
||||
onClick = ::onAcceptClick,
|
||||
size = ButtonSize.MediumLowPadding,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KnockRequestAvatarView(
|
||||
knockRequests: ImmutableList<KnockRequest>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier) {
|
||||
when (knockRequests.size) {
|
||||
0 -> Unit
|
||||
1 -> Avatar(knockRequests.first().getAvatarData(AvatarSize.KnockRequestBanner))
|
||||
else -> KnockRequestAvatarListView(knockRequests)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KnockRequestAvatarListView(
|
||||
knockRequests: ImmutableList<KnockRequest>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val avatarSize = AvatarSize.KnockRequestBanner.dp
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(-avatarSize / 2),
|
||||
) {
|
||||
knockRequests
|
||||
.take(MAX_AVATAR_COUNT)
|
||||
.forEachIndexed { index, knockRequest ->
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(size = avatarSize)
|
||||
.clip(CircleShape)
|
||||
.background(color = ElementTheme.colors.bgCanvasDefaultLevel1)
|
||||
.zIndex(-index.toFloat()),
|
||||
) {
|
||||
Avatar(
|
||||
modifier = Modifier.padding(2.dp),
|
||||
avatarData = knockRequest.getAvatarData(AvatarSize.KnockRequestBanner),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@PreviewsDayNight
|
||||
internal fun KnockRequestsBannerViewPreview(@PreviewParameter(KnockRequestsBannerStateProvider::class) state: KnockRequestsBannerState) = ElementPreview {
|
||||
KnockRequestsBannerView(
|
||||
state = state,
|
||||
onViewRequestsClick = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ package io.element.android.features.knockrequests.impl.list
|
|||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.knockrequests.impl.KnockRequest
|
||||
import io.element.android.features.knockrequests.impl.aKnockRequest
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
|
@ -101,20 +102,6 @@ open class KnockRequestsListStateProvider : PreviewParameterProvider<KnockReques
|
|||
)
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -383,7 +383,7 @@ private fun KnockRequestsEmptyList(
|
|||
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()),
|
||||
iconStyle = BigIcon.Style.Default(CompoundIcons.AskToJoin()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,4 +14,12 @@
|
|||
<string name="screen_knock_requests_list_empty_state_description">"When somebody will ask to join the room, you’ll 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>
|
||||
<plurals name="screen_room_multiple_knock_requests_title">
|
||||
<item quantity="one">"%1$s +%2$d other want to join this room"</item>
|
||||
<item quantity="other">"%1$s +%2$d others want to join this room"</item>
|
||||
</plurals>
|
||||
<string name="screen_room_multiple_knock_requests_view_all_button_title">"View all"</string>
|
||||
<string name="screen_room_single_knock_request_accept_button_title">"Accept"</string>
|
||||
<string name="screen_room_single_knock_request_title">"%1$s wants to join this room"</string>
|
||||
<string name="screen_room_single_knock_request_view_button_title">"View"</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ dependencies {
|
|||
implementation(libs.vanniktech.blurhash)
|
||||
implementation(libs.telephoto.zoomableimage)
|
||||
implementation(libs.matrix.emojibase.bindings)
|
||||
implementation(projects.features.knockrequests.api)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,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.location.api.Location
|
||||
import io.element.android.features.location.api.SendLocationEntryPoint
|
||||
import io.element.android.features.location.api.ShowLocationEntryPoint
|
||||
|
|
@ -95,6 +96,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
private val mentionSpanTheme: MentionSpanTheme,
|
||||
private val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider,
|
||||
private val timelineController: TimelineController,
|
||||
private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint,
|
||||
) : BaseFlowNode<MessagesFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = plugins.filterIsInstance<MessagesEntryPoint.Params>().first().initialTarget.toNavTarget(),
|
||||
|
|
@ -146,6 +148,9 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
|
||||
@Parcelize
|
||||
data object PinnedMessagesList : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object KnockRequestsList : NavTarget
|
||||
}
|
||||
|
||||
private val callbacks = plugins<MessagesEntryPoint.Callback>()
|
||||
|
|
@ -226,6 +231,10 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
override fun onViewAllPinnedEvents() {
|
||||
backstack.push(NavTarget.PinnedMessagesList)
|
||||
}
|
||||
|
||||
override fun onViewKnockRequests() {
|
||||
backstack.push(NavTarget.KnockRequestsList)
|
||||
}
|
||||
}
|
||||
val inputs = MessagesNode.Inputs(focusedEventId = navTarget.focusedEventId)
|
||||
createNode<MessagesNode>(buildContext, listOf(callback, inputs))
|
||||
|
|
@ -326,6 +335,9 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
NavTarget.Empty -> {
|
||||
node(buildContext) {}
|
||||
}
|
||||
NavTarget.KnockRequestsList -> {
|
||||
knockRequestsListEntryPoint.createNode(this, buildContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import dagger.assisted.Assisted
|
|||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.knockrequests.api.banner.KnockRequestsBannerRenderer
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
|
|
@ -71,6 +72,7 @@ class MessagesNode @AssistedInject constructor(
|
|||
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
|
||||
private val mediaPlayer: MediaPlayer,
|
||||
private val permalinkParser: PermalinkParser,
|
||||
private val knockRequestsBannerRenderer: KnockRequestsBannerRenderer
|
||||
) : Node(buildContext, plugins = plugins), MessagesNavigator {
|
||||
private val presenter = presenterFactory.create(
|
||||
navigator = this,
|
||||
|
|
@ -98,6 +100,7 @@ class MessagesNode @AssistedInject constructor(
|
|||
fun onEditPollClick(eventId: EventId)
|
||||
fun onJoinCallClick(roomId: RoomId)
|
||||
fun onViewAllPinnedEvents()
|
||||
fun onViewKnockRequests()
|
||||
}
|
||||
|
||||
override fun onBuilt() {
|
||||
|
|
@ -206,6 +209,10 @@ class MessagesNode @AssistedInject constructor(
|
|||
callbacks.forEach { it.onJoinCallClick(room.roomId) }
|
||||
}
|
||||
|
||||
private fun onViewKnockRequestsClick() {
|
||||
callbacks.forEach { it.onViewKnockRequests() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val activity = LocalContext.current as Activity
|
||||
|
|
@ -231,6 +238,12 @@ class MessagesNode @AssistedInject constructor(
|
|||
onCreatePollClick = this::onCreatePollClick,
|
||||
onJoinCallClick = this::onJoinCallClick,
|
||||
onViewAllPinnedMessagesClick = this::onViewAllPinnedMessagesClick,
|
||||
knockRequestsBannerView = {
|
||||
knockRequestsBannerRenderer.View(
|
||||
modifier = Modifier,
|
||||
onViewRequestsClick = this::onViewKnockRequestsClick
|
||||
)
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ fun MessagesView(
|
|||
onViewAllPinnedMessagesClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
forceJumpToBottomVisibility: Boolean = false,
|
||||
knockRequestsBannerView: @Composable () -> Unit,
|
||||
) {
|
||||
OnLifecycleEvent { _, event ->
|
||||
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.LifecycleEvent(event))
|
||||
|
|
@ -195,8 +196,8 @@ fun MessagesView(
|
|||
MessagesViewContent(
|
||||
state = state,
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding),
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding),
|
||||
onContentClick = ::onContentClick,
|
||||
onMessageLongClick = ::onMessageLongClick,
|
||||
onUserDataClick = { hidingKeyboard { onUserDataClick(it) } },
|
||||
|
|
@ -215,6 +216,7 @@ fun MessagesView(
|
|||
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
||||
onJoinCallClick = onJoinCallClick,
|
||||
onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick,
|
||||
knockRequestsBannerView = knockRequestsBannerView,
|
||||
)
|
||||
},
|
||||
snackbarHost = {
|
||||
|
|
@ -284,12 +286,13 @@ private fun MessagesViewContent(
|
|||
forceJumpToBottomVisibility: Boolean,
|
||||
onSwipeToReply: (TimelineItem.Event) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
knockRequestsBannerView: @Composable () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
.imePadding(),
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
.imePadding(),
|
||||
) {
|
||||
AttachmentsBottomSheet(
|
||||
state = state.composerState,
|
||||
|
|
@ -372,6 +375,7 @@ private fun MessagesViewContent(
|
|||
onViewAllClick = onViewAllPinnedMessagesClick,
|
||||
)
|
||||
}
|
||||
knockRequestsBannerView()
|
||||
}
|
||||
},
|
||||
sheetContent = { subcomposing: Boolean ->
|
||||
|
|
@ -398,13 +402,13 @@ private fun MessagesViewComposerBottomSheetContents(
|
|||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
SuggestionsPickerView(
|
||||
modifier = Modifier
|
||||
.heightIn(max = 230.dp)
|
||||
// Consume all scrolling, preventing the bottom sheet from being dragged when interacting with the list of suggestions
|
||||
.nestedScroll(object : NestedScrollConnection {
|
||||
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
|
||||
return available
|
||||
}
|
||||
}),
|
||||
.heightIn(max = 230.dp)
|
||||
// Consume all scrolling, preventing the bottom sheet from being dragged when interacting with the list of suggestions
|
||||
.nestedScroll(object : NestedScrollConnection {
|
||||
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
|
||||
return available
|
||||
}
|
||||
}),
|
||||
roomId = state.roomId,
|
||||
roomName = state.roomName.dataOrNull(),
|
||||
roomAvatarData = state.roomAvatar.dataOrNull(),
|
||||
|
|
@ -452,8 +456,8 @@ private fun MessagesViewTopBar(
|
|||
title = {
|
||||
val roundedCornerShape = RoundedCornerShape(8.dp)
|
||||
val titleModifier = Modifier
|
||||
.clip(roundedCornerShape)
|
||||
.clickable { onRoomDetailsClick() }
|
||||
.clip(roundedCornerShape)
|
||||
.clickable { onRoomDetailsClick() }
|
||||
if (roomName != null && roomAvatar != null) {
|
||||
RoomAvatarAndNameRow(
|
||||
roomName = roomName,
|
||||
|
|
@ -508,9 +512,9 @@ private fun RoomAvatarAndNameRow(
|
|||
private fun CantSendMessageBanner() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.secondary)
|
||||
.padding(16.dp),
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.secondary)
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
|
|
@ -539,5 +543,6 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
|
|||
onJoinCallClick = {},
|
||||
onViewAllPinnedMessagesClick = { },
|
||||
forceJumpToBottomVisibility = true,
|
||||
knockRequestsBannerView = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,5 +40,6 @@ internal fun MessagesViewWithIdentityChangePreview(
|
|||
onCreatePollClick = {},
|
||||
onJoinCallClick = {},
|
||||
onViewAllPinnedMessagesClick = {},
|
||||
knockRequestsBannerView = {}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -533,6 +533,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
|
|||
onCreatePollClick = onCreatePollClick,
|
||||
onJoinCallClick = onJoinCallClick,
|
||||
onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick,
|
||||
knockRequestsBannerView = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -242,7 +242,7 @@ fun RoomDetailsView(
|
|||
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())),
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.AskToJoin())),
|
||||
trailingContent = if (knockRequestsCount == null || knockRequestsCount == 0) {
|
||||
null
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* 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.libraries.core.extensions
|
||||
|
||||
/**
|
||||
* Returns the first element if the list contains exactly one element, otherwise returns null.
|
||||
*/
|
||||
inline fun <reified T> List<T>.firstIfSingle(): T? {
|
||||
return if (size == 1) first() else null
|
||||
}
|
||||
|
|
@ -56,4 +56,5 @@ enum class AvatarSize(val dp: Dp) {
|
|||
Suggestion(32.dp),
|
||||
|
||||
KnockRequestItem(52.dp),
|
||||
KnockRequestBanner(32.dp),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -322,18 +322,10 @@ Reason: %1$s."</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_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">
|
||||
<item quantity="one">"%1$s +%2$d other want to join this room"</item>
|
||||
<item quantity="other">"%1$s +%2$d others want to join this room"</item>
|
||||
</plurals>
|
||||
<string name="screen_room_multiple_knock_requests_view_all_button_title">"View all"</string>
|
||||
<string name="screen_room_pinned_banner_indicator">"%1$s of %2$s"</string>
|
||||
<string name="screen_room_pinned_banner_indicator_description">"%1$s Pinned messages"</string>
|
||||
<string name="screen_room_pinned_banner_loading_description">"Loading message…"</string>
|
||||
<string name="screen_room_pinned_banner_view_all_button_title">"View All"</string>
|
||||
<string name="screen_room_single_knock_request_accept_button_title">"Accept"</string>
|
||||
<string name="screen_room_single_knock_request_title">"%1$s wants to join this room"</string>
|
||||
<string name="screen_room_single_knock_request_view_button_title">"View"</string>
|
||||
<string name="screen_room_title">"Chat"</string>
|
||||
<string name="screen_roomlist_knock_event_sent_description">"Request to join sent"</string>
|
||||
<string name="screen_share_location_title">"Share location"</string>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d20f97f6422fb2eaaccd13ab12b3e27e589eaafe032a65696f3c862ccae4b743
|
||||
size 29335
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e0f508d82473c7b1dc9da20d25808843c3e1bfcac632ac2bf33151cc7626bf35
|
||||
size 34821
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5262c97044361ab44066a8876b8417853c8ef5c1449390242c0248c06a0fc568
|
||||
size 17855
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0f247767c5f965cf75f1c7556f49bd5e3c7207b2effd9cd3d19a913581556842
|
||||
size 18744
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:78f259c410ad179c3f93b18fc7c10829a355e5a0f56b0dd4c1f4b4efc72910f0
|
||||
size 27309
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d20f97f6422fb2eaaccd13ab12b3e27e589eaafe032a65696f3c862ccae4b743
|
||||
size 29335
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d20f97f6422fb2eaaccd13ab12b3e27e589eaafe032a65696f3c862ccae4b743
|
||||
size 29335
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a965026cd6257bd691bdf821dcb29c99276ab5aebff2060610acc287cebf4c35
|
||||
size 27357
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:77bc7bd3f4dab9e05ce7f68ddc50d06da03d414c33ce8e361bb83ccec38ad3c8
|
||||
size 32231
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:cff0d41eb0ab572946867cb9ea57fcab1fb72b155273fc68b12855c100cdb0c0
|
||||
size 15974
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8fcbb3f101b864cd0a5eeade5d43a76e42573705a90604706be696a810e7096c
|
||||
size 17021
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f89ebacc5f45844b3606a214a4c0df8ca150003de50bd7a04680d85ddea2ee15
|
||||
size 25224
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a965026cd6257bd691bdf821dcb29c99276ab5aebff2060610acc287cebf4c35
|
||||
size 27357
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a965026cd6257bd691bdf821dcb29c99276ab5aebff2060610acc287cebf4c35
|
||||
size 27357
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a9595edf8183796a0a0efa838a3c5265e07f70452933e6255d79f536cfca64ac
|
||||
size 26138
|
||||
oid sha256:a67bb9ba78ea1d8802ce503e7d6a16be2ff46415df0e435efd7a36f3ee711b0d
|
||||
size 26453
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:37e24295a404463010f6d36ab94b1ac3c2e193d9122637abbeb0a43abcb427c1
|
||||
size 25669
|
||||
oid sha256:be2ae50ce1b0e23fec7bcbbe9ca0f0381af4d0f29f1e7498be837d9d53b75542
|
||||
size 26003
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5abc97134f8a5f0d037367c9278d8f007543463e13c8e043241f292949681ff6
|
||||
size 17728
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:972653df5c62859c175a62d7809193afd0cb68832e3567760082f1b164e3424b
|
||||
size 16990
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:491cb82462df122b0e8d21c5b70e8db8ac19c28aaee1289db6f4774e7d31d53f
|
||||
size 19556
|
||||
|
|
@ -291,7 +291,9 @@
|
|||
{
|
||||
"name" : ":features:knockrequests:impl",
|
||||
"includeRegex" : [
|
||||
"screen\\.knock_requests_list\\..*"
|
||||
"screen\\.knock_requests_list\\..*",
|
||||
"screen\\.room\\.single_knock_request.*",
|
||||
"screen\\.room\\.multiple_knock_requests.*"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue