diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BatteryOptimizationBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BatteryOptimizationBanner.kt index 95826665fe..3feabca8b0 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BatteryOptimizationBanner.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BatteryOptimizationBanner.kt @@ -16,13 +16,11 @@ import io.element.android.libraries.designsystem.components.Announcement import io.element.android.libraries.designsystem.components.AnnouncementType import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.push.api.battery.BatteryOptimizationEvent -import io.element.android.libraries.push.api.battery.BatteryOptimizationState -import io.element.android.libraries.push.api.battery.aBatteryOptimizationState @Composable internal fun BatteryOptimizationBanner( - state: BatteryOptimizationState, + onActionClick: () -> Unit, + onDismissClick: () -> Unit, modifier: Modifier = Modifier, ) { Announcement( @@ -31,8 +29,8 @@ internal fun BatteryOptimizationBanner( description = stringResource(R.string.banner_battery_optimization_content_android), type = AnnouncementType.Actionable( actionText = stringResource(R.string.banner_battery_optimization_submit_android), - onActionClick = { state.eventSink(BatteryOptimizationEvent.RequestDisableOptimizations) }, - onDismissClick = { state.eventSink(BatteryOptimizationEvent.Dismiss) }, + onActionClick = onActionClick, + onDismissClick = onDismissClick, ), ) } @@ -41,6 +39,7 @@ internal fun BatteryOptimizationBanner( @Composable internal fun BatteryOptimizationBannerPreview() = ElementPreview { BatteryOptimizationBanner( - state = aBatteryOptimizationState(), + onActionClick = {}, + onDismissClick = {}, ) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/DismissibleBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/DismissibleBanner.kt new file mode 100644 index 0000000000..1c743ec8dd --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/DismissibleBanner.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import kotlinx.coroutines.flow.first + +/** + * Wraps a banner of the room list so that it shrinks and fades out when it is dismissed. + * [onDismiss] is invoked once the animation is over, so that the banner is removed from the list + * when its height is 0, and the items below it are not moved abruptly. + * + * @param onDismiss invoked when the banner has been dismissed and the animation is over. + * @param modifier the modifier to apply to the banner container. + * @param content the banner, which must invoke the provided lambda when the user dismisses it. + */ +@Composable +internal fun DismissibleBanner( + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable (onDismissClick: () -> Unit) -> Unit, +) { + val visibleState = remember { MutableTransitionState(true) } + val latestOnDismiss by rememberUpdatedState(onDismiss) + var isDismissNotified by remember { mutableStateOf(false) } + fun notifyDismiss() { + if (!isDismissNotified) { + isDismissNotified = true + latestOnDismiss() + } + } + LaunchedEffect(visibleState) { + snapshotFlow { visibleState.isIdle && !visibleState.currentState }.first { it } + notifyDismiss() + } + DisposableEffect(visibleState) { + onDispose { + // The item can be disposed during the animation, for instance if the user scrolls the list + if (!visibleState.targetState) { + notifyDismiss() + } + } + } + AnimatedVisibility( + visibleState = visibleState, + modifier = modifier, + enter = EnterTransition.None, + exit = shrinkVertically( + animationSpec = tween(), + shrinkTowards = Alignment.CenterVertically, + ) + fadeOut( + animationSpec = tween(), + ), + ) { + content { visibleState.targetState = false } + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/FullScreenIntentPermissionBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/FullScreenIntentPermissionBanner.kt index f2826415d2..ae5a25a623 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/FullScreenIntentPermissionBanner.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/FullScreenIntentPermissionBanner.kt @@ -16,14 +16,12 @@ import io.element.android.libraries.designsystem.components.Announcement import io.element.android.libraries.designsystem.components.AnnouncementType import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvent -import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState -import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState import io.element.android.libraries.ui.strings.CommonStrings @Composable fun FullScreenIntentPermissionBanner( - state: FullScreenIntentPermissionsState, + onActionClick: () -> Unit, + onDismissClick: () -> Unit, modifier: Modifier = Modifier ) { Announcement( @@ -31,8 +29,8 @@ fun FullScreenIntentPermissionBanner( description = stringResource(R.string.full_screen_intent_banner_message), type = AnnouncementType.Actionable( actionText = stringResource(CommonStrings.action_continue), - onDismissClick = { state.eventSink(FullScreenIntentPermissionsEvent.Dismiss) }, - onActionClick = { state.eventSink(FullScreenIntentPermissionsEvent.OpenSettings) }, + onDismissClick = onDismissClick, + onActionClick = onActionClick, ), modifier = modifier.roomListBannerPadding(), ) @@ -42,6 +40,9 @@ fun FullScreenIntentPermissionBanner( @Composable internal fun FullScreenIntentPermissionBannerPreview() { ElementPreview { - FullScreenIntentPermissionBanner(aFullScreenIntentPermissionsState()) + FullScreenIntentPermissionBanner( + onActionClick = {}, + onDismissClick = {}, + ) } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt index 68d258431d..874f4f4784 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt @@ -52,6 +52,8 @@ import io.element.android.libraries.designsystem.theme.components.HorizontalDivi import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect +import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvent +import io.element.android.libraries.push.api.battery.BatteryOptimizationEvent import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList @@ -161,16 +163,24 @@ private fun EmptyView( Box { when (state.securityBannerState) { SecurityBannerState.SetUpRecovery -> { - SetUpRecoveryKeyBanner( - onContinueClick = onSetUpRecoveryClick, - onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, - ) + DismissibleBanner( + onDismiss = { eventSink(RoomListEvent.DismissBanner) }, + ) { onDismissClick -> + SetUpRecoveryKeyBanner( + onContinueClick = onSetUpRecoveryClick, + onDismissClick = onDismissClick, + ) + } } SecurityBannerState.RecoveryKeyConfirmation -> { - ConfirmRecoveryKeyBanner( - onContinueClick = onConfirmRecoveryKeyClick, - onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, - ) + DismissibleBanner( + onDismiss = { eventSink(RoomListEvent.DismissBanner) }, + ) { onDismissClick -> + ConfirmRecoveryKeyBanner( + onContinueClick = onConfirmRecoveryKeyClick, + onDismissClick = onDismissClick, + ) + } } SecurityBannerState.None -> Unit } @@ -228,60 +238,26 @@ private fun RoomsViewList( modifier: Modifier = Modifier, ) { OnVisibleRangeChangeEffect(lazyListState) { visibleRange -> - eventSink(RoomListEvent.UpdateVisibleRange(visibleRange)) + // The first item of the list is the banner slot, remove it to get the range of the rooms + val roomsRange = (visibleRange.first - 1).coerceAtLeast(0)..(visibleRange.last - 1) + eventSink(RoomListEvent.UpdateVisibleRange(roomsRange)) } LazyColumn( state = lazyListState, modifier = modifier, contentPadding = contentPadding, ) { - when (state.securityBannerState) { - SecurityBannerState.SetUpRecovery -> { - item { - SetUpRecoveryKeyBanner( - onContinueClick = onSetUpRecoveryClick, - onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, - ) - } - } - SecurityBannerState.RecoveryKeyConfirmation -> { - item { - ConfirmRecoveryKeyBanner( - onContinueClick = onConfirmRecoveryKeyClick, - onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, - ) - } - } - // Banner precedence (top-to-bottom): full-screen-intent > battery-optimization > - // new-notification-sound > swipe-avatar > sound-unavailable. At most one renders at a time. - SecurityBannerState.None -> when { - state.fullScreenIntentPermissionsState.shouldDisplayBanner -> { - item { - FullScreenIntentPermissionBanner(state = state.fullScreenIntentPermissionsState) - } - } - state.batteryOptimizationState.shouldDisplayBanner -> { - item { - BatteryOptimizationBanner(state = state.batteryOptimizationState) - } - } - state.showNewNotificationSoundBanner -> { - item { - NewNotificationSoundBanner( - onDismissClick = { eventSink(RoomListEvent.DismissNewNotificationSoundBanner) }, - ) - } - } - state.showSwipeAvatarBanner -> { - item { - SwipeAvatarBanner( - onDismissClick = { eventSink(RoomListEvent.DismissSwipeAvatarBanner) }, - ) - } - } - } + // The banner slot is always the first item of the list, even when there is no banner to display, + // so that the rooms keep the same index when a banner is added or removed. Since the rooms do not + // have a key, this is required to keep the scroll position. + item { + RoomsViewBanner( + state = state, + eventSink = eventSink, + onSetUpRecoveryClick = onSetUpRecoveryClick, + onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, + ) } - // Note: do not use a key for the LazyColumn, or the scroll will not behave as expected if a room // is moved to the top of the list. itemsIndexed( @@ -305,6 +281,79 @@ private fun RoomsViewList( } } +@Composable +private fun RoomsViewBanner( + state: RoomListContentState.Rooms, + eventSink: (RoomListEvent) -> Unit, + onSetUpRecoveryClick: () -> Unit, + onConfirmRecoveryKeyClick: () -> Unit, +) { + when (state.securityBannerState) { + SecurityBannerState.SetUpRecovery -> { + DismissibleBanner( + onDismiss = { eventSink(RoomListEvent.DismissBanner) }, + ) { onDismissClick -> + SetUpRecoveryKeyBanner( + onContinueClick = onSetUpRecoveryClick, + onDismissClick = onDismissClick, + ) + } + } + SecurityBannerState.RecoveryKeyConfirmation -> { + DismissibleBanner( + onDismiss = { eventSink(RoomListEvent.DismissBanner) }, + ) { onDismissClick -> + ConfirmRecoveryKeyBanner( + onContinueClick = onConfirmRecoveryKeyClick, + onDismissClick = onDismissClick, + ) + } + } + // Banner precedence (top-to-bottom): full-screen-intent > battery-optimization > + // new-notification-sound > swipe-avatar > sound-unavailable. At most one renders at a time. + SecurityBannerState.None -> when { + state.fullScreenIntentPermissionsState.shouldDisplayBanner -> { + DismissibleBanner( + onDismiss = { state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvent.Dismiss) }, + ) { onDismissClick -> + FullScreenIntentPermissionBanner( + onActionClick = { state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvent.OpenSettings) }, + onDismissClick = onDismissClick, + ) + } + } + state.batteryOptimizationState.shouldDisplayBanner -> { + DismissibleBanner( + onDismiss = { state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.Dismiss) }, + ) { onDismissClick -> + BatteryOptimizationBanner( + onActionClick = { state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.RequestDisableOptimizations) }, + onDismissClick = onDismissClick, + ) + } + } + state.showNewNotificationSoundBanner -> { + DismissibleBanner( + onDismiss = { eventSink(RoomListEvent.DismissNewNotificationSoundBanner) }, + ) { onDismissClick -> + NewNotificationSoundBanner( + onDismissClick = onDismissClick, + ) + } + } + state.showSwipeAvatarBanner -> { + DismissibleBanner( + onDismiss = { eventSink(RoomListEvent.DismissSwipeAvatarBanner) }, + ) { onDismissClick -> + SwipeAvatarBanner( + onDismissClick = onDismissClick, + ) + } + } + } + } +} + @Composable private fun EmptyViewForFilterStates( selectedFilters: ImmutableList, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt index 2c158d3ef9..e93aef71f5 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt @@ -51,9 +51,10 @@ class RoomListViewTest : RobolectricTest() { ) ) + // The banner is not included in the range, only the rooms are eventsRecorder.assertList( listOf( - RoomListEvent.UpdateVisibleRange(0..5), + RoomListEvent.UpdateVisibleRange(0..4), ) ) } @@ -64,15 +65,19 @@ class RoomListViewTest : RobolectricTest() { setRoomListView( state = aRoomListState( contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation), - eventSink = eventsRecorder, + // Ignore the visible range updates, which are also emitted when the banner is shrinking + eventSink = { event -> + if (event !is RoomListEvent.UpdateVisibleRange) { + eventsRecorder(event) + } + }, ) ) - // Remove automatic initial events - eventsRecorder.clear() - val close = activity!!.getString(CommonStrings.action_close) onNodeWithContentDescription(close).performClick() + // The event is emitted at the end of the animation + waitForIdle() eventsRecorder.assertSingle(RoomListEvent.DismissBanner) } @@ -82,15 +87,19 @@ class RoomListViewTest : RobolectricTest() { setRoomListView( state = aRoomListState( contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery), - eventSink = eventsRecorder, + // Ignore the visible range updates, which are also emitted when the banner is shrinking + eventSink = { event -> + if (event !is RoomListEvent.UpdateVisibleRange) { + eventsRecorder(event) + } + }, ) ) - // Remove automatic initial events - eventsRecorder.clear() - val close = activity!!.getString(CommonStrings.action_close) onNodeWithContentDescription(close).performClick() + // The event is emitted at the end of the animation + waitForIdle() eventsRecorder.assertSingle(RoomListEvent.DismissBanner) } @@ -100,7 +109,7 @@ class RoomListViewTest : RobolectricTest() { setRoomListView( state = aRoomListState( contentState = aRoomsContentState(showSwipeAvatarBanner = true), - // Ignore the visible range updates + // Ignore the visible range updates, which are also emitted when the banner is shrinking eventSink = { event -> if (event !is RoomListEvent.UpdateVisibleRange) { eventsRecorder(event) @@ -112,6 +121,8 @@ class RoomListViewTest : RobolectricTest() { eventsRecorder.assertEmpty() val close = activity!!.getString(CommonStrings.action_close) onNodeWithContentDescription(close).performClick() + // The event is emitted at the end of the animation + waitForIdle() eventsRecorder.assertSingle(RoomListEvent.DismissSwipeAvatarBanner) }