Merge pull request #7904 from element-hq/feature/bma/swipeAvatarAnnouncement

Announce the swipe gesture on the account avatar to switch account
This commit is contained in:
Benoit Marty
2026-10-14 18:19:48 +02:00
committed by GitHub
48 changed files with 489 additions and 134 deletions
@@ -19,4 +19,6 @@ sealed interface Announcement {
data object NewNotificationSound : Announcement
data object MultiAccount : Announcement
data object SwipeAvatarToSwitchAccount : Announcement
}
@@ -34,6 +34,7 @@ class DefaultAnnouncementService(
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show)
}
Announcement.MultiAccount -> showAnnouncementIfNeverShown(announcement)
Announcement.SwipeAvatarToSwitchAccount -> showAnnouncementIfNeverShown(announcement)
}
}
@@ -46,7 +47,8 @@ class DefaultAnnouncementService(
flowOf(Unit),
announcementStore.announcementStatusFlow(Announcement.NewNotificationSound),
announcementStore.announcementStatusFlow(Announcement.MultiAccount),
) { _, newNotificationSoundStatus, multiAccountStatus ->
announcementStore.announcementStatusFlow(Announcement.SwipeAvatarToSwitchAccount),
) { _, newNotificationSoundStatus, multiAccountStatus, swipeAvatarToSwitchAccountStatus ->
buildList {
if (newNotificationSoundStatus == AnnouncementStatus.Show) {
add(Announcement.NewNotificationSound)
@@ -56,6 +58,10 @@ class DefaultAnnouncementService(
if (multiAccountStatus != AnnouncementStatus.Shown) {
add(Announcement.MultiAccount)
}
// The swipe avatar announcement is pending until it is dismissed, or the user switches account by swiping.
if (swipeAvatarToSwitchAccountStatus != AnnouncementStatus.Shown) {
add(Announcement.SwipeAvatarToSwitchAccount)
}
}
}
}
@@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.map
private val spaceAnnouncementKey = intPreferencesKey("spaceAnnouncement")
private val newNotificationSoundKey = intPreferencesKey("newNotificationSound")
private val multiAccountKey = intPreferencesKey("multiAccountAnnouncement")
private val swipeAvatarToSwitchAccountKey = intPreferencesKey("swipeAvatarToSwitchAccountAnnouncement")
@ContributesBinding(AppScope::class)
class DefaultAnnouncementStore(
@@ -42,10 +43,13 @@ class DefaultAnnouncementStore(
// - NewNotificationSound is opted in by AppMigration08
// - MultiAccount is NeverShown by default, which means pending. It is set to Shown when the user enters
// the login flow while the feature is enabled (see NotLoggedInFlowNode), or dismisses the announcement.
// - SwipeAvatarToSwitchAccount is NeverShown by default, which means pending. It is set to Shown when the user
// dismisses the banner, or switches account by swiping their avatar.
val defaultStatus = when (announcement) {
Announcement.Fullscreen.Space -> AnnouncementStatus.Shown
Announcement.NewNotificationSound -> AnnouncementStatus.Shown
Announcement.MultiAccount -> AnnouncementStatus.NeverShown
Announcement.SwipeAvatarToSwitchAccount -> AnnouncementStatus.NeverShown
}
return store.data.map { prefs ->
val ordinal = prefs[key] ?: defaultStatus.ordinal
@@ -62,4 +66,5 @@ private fun Announcement.toKey() = when (this) {
Announcement.Fullscreen.Space -> spaceAnnouncementKey
Announcement.NewNotificationSound -> newNotificationSoundKey
Announcement.MultiAccount -> multiAccountKey
Announcement.SwipeAvatarToSwitchAccount -> swipeAvatarToSwitchAccountKey
}
@@ -67,6 +67,22 @@ class DefaultAnnouncementServiceTest {
assertThat(announcementStore.announcementStatusFlow(Announcement.MultiAccount).first()).isEqualTo(AnnouncementStatus.Shown)
}
@Test
fun `when showing SwipeAvatarToSwitchAccount announcement, announcement is set to show only if it was never shown`() = runTest {
val announcementStore = InMemoryAnnouncementStore()
val sut = createDefaultAnnouncementService(
announcementStore = announcementStore,
)
assertThat(announcementStore.announcementStatusFlow(Announcement.SwipeAvatarToSwitchAccount).first()).isEqualTo(AnnouncementStatus.NeverShown)
sut.showAnnouncement(Announcement.SwipeAvatarToSwitchAccount)
assertThat(announcementStore.announcementStatusFlow(Announcement.SwipeAvatarToSwitchAccount).first()).isEqualTo(AnnouncementStatus.Show)
// Simulate user close the announcement
sut.onAnnouncementDismissed(Announcement.SwipeAvatarToSwitchAccount)
// Calling again showAnnouncement should not change the value
sut.showAnnouncement(Announcement.SwipeAvatarToSwitchAccount)
assertThat(announcementStore.announcementStatusFlow(Announcement.SwipeAvatarToSwitchAccount).first()).isEqualTo(AnnouncementStatus.Shown)
}
@Test
fun `test announcementsToShowFlow`() = runTest {
val announcementStore = InMemoryAnnouncementStore()
@@ -74,16 +90,18 @@ class DefaultAnnouncementServiceTest {
announcementStore = announcementStore,
)
sut.announcementsToShowFlow().test {
// MultiAccount is pending by default
assertThat(awaitItem()).containsExactly(Announcement.MultiAccount)
// MultiAccount and SwipeAvatarToSwitchAccount are pending by default
assertThat(awaitItem()).containsExactly(Announcement.MultiAccount, Announcement.SwipeAvatarToSwitchAccount)
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show)
assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount)
assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount, Announcement.SwipeAvatarToSwitchAccount)
announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Show)
// Still pending
assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount)
assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount, Announcement.SwipeAvatarToSwitchAccount)
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Shown)
assertThat(awaitItem()).containsExactly(Announcement.MultiAccount)
assertThat(awaitItem()).containsExactly(Announcement.MultiAccount, Announcement.SwipeAvatarToSwitchAccount)
announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Shown)
assertThat(awaitItem()).containsExactly(Announcement.SwipeAvatarToSwitchAccount)
announcementStore.setAnnouncementStatus(Announcement.SwipeAvatarToSwitchAccount, AnnouncementStatus.Shown)
assertThat(awaitItem()).isEmpty()
}
}
@@ -17,10 +17,12 @@ class InMemoryAnnouncementStore(
initialFullscreenAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
initialNewNotificationSoundAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
initialMultiAccountAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
initialSwipeAvatarToSwitchAccountAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
) : AnnouncementStore {
private val fullScreenAnnouncement = MutableStateFlow(initialFullscreenAnnouncementStatus)
private val newNotificationSoundAnnouncement = MutableStateFlow(initialNewNotificationSoundAnnouncementStatus)
private val multiAccountAnnouncement = MutableStateFlow(initialMultiAccountAnnouncementStatus)
private val swipeAvatarToSwitchAccountAnnouncement = MutableStateFlow(initialSwipeAvatarToSwitchAccountAnnouncementStatus)
override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStatus) {
announcement.toMutableStateFlow().value = status
@@ -34,11 +36,13 @@ class InMemoryAnnouncementStore(
fullScreenAnnouncement.value = AnnouncementStatus.NeverShown
newNotificationSoundAnnouncement.value = AnnouncementStatus.NeverShown
multiAccountAnnouncement.value = AnnouncementStatus.NeverShown
swipeAvatarToSwitchAccountAnnouncement.value = AnnouncementStatus.NeverShown
}
private fun Announcement.toMutableStateFlow() = when (this) {
is Announcement.Fullscreen -> fullScreenAnnouncement
Announcement.NewNotificationSound -> newNotificationSoundAnnouncement
Announcement.MultiAccount -> multiAccountAnnouncement
Announcement.SwipeAvatarToSwitchAccount -> swipeAvatarToSwitchAccountAnnouncement
}
}
@@ -11,4 +11,5 @@ package io.element.android.features.home.impl
sealed interface HomeEvent {
data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvent
data object DismissMultiAccountAnnouncement : HomeEvent
data object DismissSwipeAvatarAnnouncement : HomeEvent
}
@@ -28,6 +28,7 @@ import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.indicator.api.IndicatorService
@@ -35,6 +36,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.combine
@@ -56,13 +58,15 @@ class HomePresenter(
private val sessionStore: SessionStore,
private val announcementService: AnnouncementService,
private val featureFlagService: FeatureFlagService,
@AppCoroutineScope
private val appCoroutineScope: CoroutineScope,
) : Presenter<HomeState> {
private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder()
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
@Composable
override fun present(): HomeState {
val coroutineState = rememberCoroutineScope()
val coroutineScope = rememberCoroutineScope()
val matrixUser by client.userProfile.collectAsState()
val totalUnreadCount by produceState(client.totalUnreadCount().getOrDefault(0).toInt()) {
@@ -126,7 +130,11 @@ class HomePresenter(
is HomeEvent.SelectHomeNavigationBarItem -> {
currentHomeNavigationBarItemOrdinal = event.item.ordinal
}
HomeEvent.DismissMultiAccountAnnouncement -> coroutineState.launch {
// The user is switching account, which destroys this presenter, so use the app scope to not be cancelled.
HomeEvent.DismissSwipeAvatarAnnouncement -> appCoroutineScope.launch {
announcementService.onAnnouncementDismissed(Announcement.SwipeAvatarToSwitchAccount)
}
HomeEvent.DismissMultiAccountAnnouncement -> coroutineScope.launch {
announcementService.onAnnouncementDismissed(Announcement.MultiAccount)
}
}
@@ -217,7 +217,11 @@ private fun HomeScaffold(
},
onMenuActionClick = onMenuActionClick,
onOpenSettings = onOpenSettings,
onAccountSwitch = onSwitchToAccount,
onAccountSwitch = { sessionId ->
// The user has switched account by swiping their avatar, so they know the gesture.
state.eventSink(HomeEvent.DismissSwipeAvatarAnnouncement)
onSwitchToAccount(sessionId)
},
scrollBehavior = scrollBehavior,
displayFilters = state.displayRoomListFilters,
filtersState = roomListState.filtersState,
@@ -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 = {},
)
}
@@ -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 = {},
)
}
}
@@ -92,6 +92,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.filter
@Composable
fun HomeTopBar(
@@ -307,9 +308,12 @@ private fun NavigationIcon(
// Listen to page changes and switch account if needed
val latestOnAccountSwitch by rememberUpdatedState(onAccountSwitch)
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.settledPage }.collect { page ->
latestOnAccountSwitch(SessionId(currentUserAndNeighbors[page].userId.value))
}
snapshotFlow { pagerState.settledPage }
// The current account is on page 1, so there is nothing to switch to
.filter { page -> page != 1 }
.collect { page ->
latestOnAccountSwitch(SessionId(currentUserAndNeighbors[page].userId.value))
}
}
VerticalPager(
state = pagerState,
@@ -8,6 +8,12 @@
package io.element.android.features.home.impl.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -15,7 +21,9 @@ import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
@@ -24,6 +32,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
@@ -39,10 +48,11 @@ import io.element.android.features.home.impl.filters.aRoomListFiltersState
import io.element.android.features.home.impl.filters.selection.FilterSelectionState
import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.home.impl.model.RoomSummaryDisplayType
import io.element.android.features.home.impl.roomlist.RoomListBanner
import io.element.android.features.home.impl.roomlist.RoomListContentState
import io.element.android.features.home.impl.roomlist.RoomListContentStatePreviewParam
import io.element.android.features.home.impl.roomlist.RoomListEvent
import io.element.android.features.home.impl.roomlist.SecurityBannerState
import io.element.android.features.home.impl.roomlist.toRoomListBanner
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
import io.element.android.features.home.impl.spacefilters.anUnselectedSpaceFiltersState
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -52,6 +62,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
@@ -158,22 +170,20 @@ private fun EmptyView(
},
modifier = Modifier.align(Alignment.Center),
)
Box {
when (state.securityBannerState) {
SecurityBannerState.SetUpRecovery -> {
SetUpRecoveryKeyBanner(
onContinueClick = onSetUpRecoveryClick,
onDismissClick = { eventSink(RoomListEvent.DismissBanner) },
)
}
SecurityBannerState.RecoveryKeyConfirmation -> {
ConfirmRecoveryKeyBanner(
onContinueClick = onConfirmRecoveryKeyClick,
onDismissClick = { eventSink(RoomListEvent.DismissBanner) },
)
}
SecurityBannerState.None -> Unit
}
RoomListBannerContainer(
banner = state.securityBannerState.toRoomListBanner(),
) { banner ->
RoomListBannerView(
banner = banner,
onActionClick = {
when (banner) {
RoomListBanner.SetUpRecovery -> onSetUpRecoveryClick()
RoomListBanner.RecoveryKeyConfirmation -> onConfirmRecoveryKeyClick()
else -> Unit
}
},
onDismissClick = { eventSink(RoomListEvent.DismissBanner) },
)
}
}
}
@@ -227,54 +237,56 @@ private fun RoomsViewList(
lazyListState: LazyListState,
modifier: Modifier = Modifier,
) {
val banner = state.toRoomListBanner()
// The banner slot must never be 0px high, else the LazyColumn considers that it is not visible and it is
// removed from the composition. A new banner would then be added above the visible area of the list.
val bannerSlotMinHeight = with(LocalDensity.current) { 1.toDp() }
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
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 > 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) },
)
}
}
// 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 {
RoomListBannerContainer(
banner = banner,
modifier = Modifier.heightIn(min = bannerSlotMinHeight),
) { displayedBanner ->
RoomListBannerView(
banner = displayedBanner,
onActionClick = {
when (displayedBanner) {
RoomListBanner.SetUpRecovery -> onSetUpRecoveryClick()
RoomListBanner.RecoveryKeyConfirmation -> onConfirmRecoveryKeyClick()
RoomListBanner.FullScreenIntentPermission ->
state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvent.OpenSettings)
RoomListBanner.BatteryOptimization ->
state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.RequestDisableOptimizations)
RoomListBanner.NewNotificationSound,
RoomListBanner.SwipeAvatar -> Unit
}
},
onDismissClick = {
when (displayedBanner) {
RoomListBanner.SetUpRecovery,
RoomListBanner.RecoveryKeyConfirmation -> eventSink(RoomListEvent.DismissBanner)
RoomListBanner.FullScreenIntentPermission ->
state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvent.Dismiss)
RoomListBanner.BatteryOptimization -> state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.Dismiss)
RoomListBanner.NewNotificationSound -> eventSink(RoomListEvent.DismissNewNotificationSoundBanner)
RoomListBanner.SwipeAvatar -> eventSink(RoomListEvent.DismissSwipeAvatarBanner)
}
},
)
}
}
// 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(
@@ -298,6 +310,73 @@ private fun RoomsViewList(
}
}
/**
* Displays the [banner] at the top of the room list, and animates every change: when the banner is
* dismissed, when it is replaced by another banner, and when a banner appears.
* The size of the slot is animated, so that the items below it are not moved abruptly.
*
* @param banner the banner to display, or null if there is no banner to display.
* @param modifier the modifier to apply to the slot.
* @param content renders the given banner.
*/
@Composable
private fun RoomListBannerContainer(
banner: RoomListBanner?,
modifier: Modifier = Modifier,
content: @Composable (RoomListBanner) -> Unit,
) {
AnimatedContent(
targetState = banner,
// Fill the width so that only the height is animated
modifier = modifier.fillMaxWidth(),
transitionSpec = {
fadeIn(tween(durationMillis = HALF_DURATION_MILLIS, delayMillis = HALF_DURATION_MILLIS)) togetherWith
fadeOut(tween(durationMillis = HALF_DURATION_MILLIS)) using
SizeTransform { _, _ -> tween(durationMillis = 2 * HALF_DURATION_MILLIS) }
},
contentAlignment = Alignment.Center,
label = "RoomListBannerContainer",
) { targetBanner ->
if (targetBanner != null) {
content(targetBanner)
}
}
}
private const val HALF_DURATION_MILLIS = 150
@Composable
private fun RoomListBannerView(
banner: RoomListBanner,
onActionClick: () -> Unit,
onDismissClick: () -> Unit,
) {
when (banner) {
RoomListBanner.SetUpRecovery -> SetUpRecoveryKeyBanner(
onContinueClick = onActionClick,
onDismissClick = onDismissClick,
)
RoomListBanner.RecoveryKeyConfirmation -> ConfirmRecoveryKeyBanner(
onContinueClick = onActionClick,
onDismissClick = onDismissClick,
)
RoomListBanner.FullScreenIntentPermission -> FullScreenIntentPermissionBanner(
onActionClick = onActionClick,
onDismissClick = onDismissClick,
)
RoomListBanner.BatteryOptimization -> BatteryOptimizationBanner(
onActionClick = onActionClick,
onDismissClick = onDismissClick,
)
RoomListBanner.NewNotificationSound -> NewNotificationSoundBanner(
onDismissClick = onDismissClick,
)
RoomListBanner.SwipeAvatar -> SwipeAvatarBanner(
onDismissClick = onDismissClick,
)
}
}
@Composable
private fun EmptyViewForFilterStates(
selectedFilters: ImmutableList<RoomListFilter>,
@@ -0,0 +1,43 @@
/*
* 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.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.features.home.impl.R
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.ui.strings.CommonStrings
@Composable
internal fun SwipeAvatarBanner(
onDismissClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Announcement(
modifier = modifier.roomListBannerPadding(),
title = stringResource(R.string.banner_swipe_avatar_title),
description = stringResource(R.string.banner_swipe_avatar_content),
type = AnnouncementType.Actionable(
actionText = stringResource(CommonStrings.action_ok),
onActionClick = onDismissClick,
onDismissClick = onDismissClick,
),
)
}
@PreviewsDayNight
@Composable
internal fun SwipeAvatarBannerPreview() = ElementPreview {
SwipeAvatarBanner(
onDismissClick = {},
)
}
@@ -0,0 +1,40 @@
/*
* 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.roomlist
/**
* The banners which can be displayed at the top of the room list. At most one banner is displayed at a time.
*/
internal enum class RoomListBanner {
SetUpRecovery,
RecoveryKeyConfirmation,
FullScreenIntentPermission,
BatteryOptimization,
NewNotificationSound,
SwipeAvatar,
}
internal fun SecurityBannerState.toRoomListBanner(): RoomListBanner? = when (this) {
SecurityBannerState.SetUpRecovery -> RoomListBanner.SetUpRecovery
SecurityBannerState.RecoveryKeyConfirmation -> RoomListBanner.RecoveryKeyConfirmation
SecurityBannerState.None -> null
}
/**
* Banner precedence (top-to-bottom): security > full-screen-intent > battery-optimization >
* new-notification-sound > swipe-avatar. At most one renders at a time.
*/
internal fun RoomListContentState.Rooms.toRoomListBanner(): RoomListBanner? {
return securityBannerState.toRoomListBanner() ?: when {
fullScreenIntentPermissionsState.shouldDisplayBanner -> RoomListBanner.FullScreenIntentPermission
batteryOptimizationState.shouldDisplayBanner -> RoomListBanner.BatteryOptimization
showNewNotificationSoundBanner -> RoomListBanner.NewNotificationSound
showSwipeAvatarBanner -> RoomListBanner.SwipeAvatar
else -> null
}
}
@@ -30,12 +30,16 @@ open class RoomListContentStatePreviewParam : PreviewParameterProvider<RoomListC
aRoomsContentState(
showNewNotificationSoundBanner = true,
),
aRoomsContentState(
showSwipeAvatarBanner = true,
),
)
}
internal fun aRoomsContentState(
securityBannerState: SecurityBannerState = SecurityBannerState.None,
showNewNotificationSoundBanner: Boolean = false,
showSwipeAvatarBanner: Boolean = false,
showUnreadCount: Boolean = false,
summaries: ImmutableList<RoomListRoomSummary> = aRoomListRoomSummaryList(),
fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(),
@@ -45,6 +49,7 @@ internal fun aRoomsContentState(
) = RoomListContentState.Rooms(
securityBannerState = securityBannerState,
showNewNotificationSoundBanner = showNewNotificationSoundBanner,
showSwipeAvatarBanner = showSwipeAvatarBanner,
showUnreadCount = showUnreadCount,
fullScreenIntentPermissionsState = fullScreenIntentPermissionsState,
batteryOptimizationState = batteryOptimizationState,
@@ -16,6 +16,7 @@ sealed interface RoomListEvent {
data object DismissRequestVerificationPrompt : RoomListEvent
data object DismissBanner : RoomListEvent
data object DismissNewNotificationSoundBanner : RoomListEvent
data object DismissSwipeAvatarBanner : RoomListEvent
data object ToggleSearchResults : RoomListEvent
data class ShowContextMenu(val roomSummary: RoomListRoomSummary) : RoomListEvent
@@ -56,6 +56,7 @@ import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar
import io.element.android.libraries.push.api.battery.BatteryOptimizationState
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
@@ -64,6 +65,7 @@ import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
@@ -89,6 +91,7 @@ class RoomListPresenter(
private val spaceFiltersPresenter: Presenter<SpaceFiltersState>,
private val globalSearchPresenter: Presenter<GlobalSearchState>,
private val featureFlagService: FeatureFlagService,
private val sessionStore: SessionStore,
) : Presenter<RoomListState> {
private val encryptionService = client.encryptionService
@@ -112,6 +115,15 @@ class RoomListPresenter(
announcements.contains(Announcement.NewNotificationSound)
}
}.collectAsState(false)
val showSwipeAvatarBanner by remember {
combine(
announcementService.announcementsToShowFlow(),
sessionStore.sessionsFlow(),
) { announcements, sessions ->
// The swipe gesture on the avatar is only available when there are several accounts
sessions.size >= 2 && announcements.contains(Announcement.SwipeAvatarToSwitchAccount)
}
}.collectAsState(false)
// Avatar indicator
val hideInvitesAvatar by client.rememberHideInvitesAvatar()
@@ -133,6 +145,9 @@ class RoomListPresenter(
RoomListEvent.DismissNewNotificationSoundBanner -> coroutineScope.launch {
announcementService.onAnnouncementDismissed(Announcement.NewNotificationSound)
}
RoomListEvent.DismissSwipeAvatarBanner -> coroutineScope.launch {
announcementService.onAnnouncementDismissed(Announcement.SwipeAvatarToSwitchAccount)
}
RoomListEvent.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvent.ToggleSearchVisibility)
is RoomListEvent.ShowContextMenu -> {
coroutineScope.showContextMenu(event, contextMenu)
@@ -176,6 +191,7 @@ class RoomListPresenter(
val contentState = roomListContentState(
securityBannerDismissed,
showNewNotificationSoundBanner,
showSwipeAvatarBanner,
showUnreadCount,
showAllActivity,
)
@@ -235,6 +251,7 @@ class RoomListPresenter(
private fun roomListContentState(
securityBannerDismissed: Boolean,
showNewNotificationSoundBanner: Boolean,
showSwipeAvatarBanner: Boolean,
showUnreadCount: Boolean,
showAllActivity: Boolean,
): RoomListContentState {
@@ -265,6 +282,7 @@ class RoomListPresenter(
RoomListContentState.Rooms(
securityBannerState = securityBannerState,
showNewNotificationSoundBanner = showNewNotificationSoundBanner,
showSwipeAvatarBanner = showSwipeAvatarBanner,
showUnreadCount = showUnreadCount,
fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(),
batteryOptimizationState = batteryOptimizationPresenter.present(),
@@ -73,6 +73,7 @@ sealed interface RoomListContentState {
val fullScreenIntentPermissionsState: FullScreenIntentPermissionsState,
val batteryOptimizationState: BatteryOptimizationState,
val showNewNotificationSoundBanner: Boolean,
val showSwipeAvatarBanner: Boolean,
val showUnreadCount: Boolean,
val summaries: ImmutableList<RoomListRoomSummary>,
val seenRoomInvites: ImmutableSet<RoomId>,
@@ -8,6 +8,8 @@
<string name="banner_set_up_recovery_content">"Your chats are automatically backed up with end-to-end encryption. To restore this backup and retain your digital identity when you lose access to all your devices, you will need your recovery key."</string>
<string name="banner_set_up_recovery_submit">"Get recovery key"</string>
<string name="banner_set_up_recovery_title">"Back up your chats"</string>
<string name="banner_swipe_avatar_content">"Swipe up or down on your avatar to switch between your accounts without going to the app settings."</string>
<string name="banner_swipe_avatar_title">"Swipe your avatar to switch accounts"</string>
<string name="confirm_recovery_key_banner_message">"Confirm your recovery key to maintain access to your key storage and message history."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Enter your recovery key"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Forgot your recovery key?"</string>
@@ -39,8 +39,12 @@ import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@@ -119,6 +123,7 @@ class HomePresenterTest {
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - total unread count is updated on sync heartbeat`() = runTest {
var unreadCount = 0L
@@ -136,6 +141,7 @@ class HomePresenterTest {
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - total unread count defaults to 0 when the SDK call fails`() = runTest {
val syncService = FakeSyncService()
@@ -280,6 +286,25 @@ class HomePresenterTest {
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - dismissing the swipe avatar announcement`() = runTest {
val onAnnouncementDismissedResult = lambdaRecorder<Announcement, Unit> { }
val presenter = createHomePresenter(
announcementService = FakeAnnouncementService(
onAnnouncementDismissedResult = onAnnouncementDismissedResult,
),
appCoroutineScope = this,
)
presenter.test {
val state = expectMostRecentItem()
state.eventSink(HomeEvent.DismissSwipeAvatarAnnouncement)
runCurrent()
onAnnouncementDismissedResult.assertions().isCalledOnce()
.with(value(Announcement.SwipeAvatarToSwitchAccount))
}
}
@Test
fun `present - total unread count is loaded in the initial state`() = runTest {
val presenter = createHomePresenter(
@@ -291,7 +316,7 @@ class HomePresenterTest {
}
}
internal fun createHomePresenter(
internal fun TestScope.createHomePresenter(
client: MatrixClient = FakeMatrixClient(),
syncService: SyncService = FakeSyncService(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
@@ -301,6 +326,7 @@ internal fun createHomePresenter(
sessionStore: SessionStore = InMemorySessionStore(),
announcementService: AnnouncementService = FakeAnnouncementService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
appCoroutineScope: CoroutineScope = backgroundScope,
) = HomePresenter(
client = client,
syncService = syncService,
@@ -312,4 +338,5 @@ internal fun createHomePresenter(
sessionStore = sessionStore,
announcementService = announcementService,
featureFlagService = featureFlagService,
appCoroutineScope = appCoroutineScope,
)
@@ -56,6 +56,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_ROOM_ID_3
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
@@ -72,6 +73,9 @@ import io.element.android.libraries.preferences.test.InMemorySessionPreferencesS
import io.element.android.libraries.push.api.battery.aBatteryOptimizationState
import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher
@@ -645,6 +649,48 @@ class RoomListPresenterTest {
}
}
@Test
fun `present - swipe avatar banner`() = runTest {
val roomList = FakeDynamicRoomList(
summaries = MutableStateFlow(listOf(aRoomSummary())),
loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1))
)
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList },
subscribeToVisibleRoomsLambda = { },
)
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
)
val onAnnouncementDismissedResult = lambdaRecorder<Announcement, Unit> { }
val announcementService = FakeAnnouncementService(
initialAnnouncementsToShowFlowValue = listOf(Announcement.SwipeAvatarToSwitchAccount),
onAnnouncementDismissedResult = onAnnouncementDismissedResult,
)
val sessionStore = InMemorySessionStore(
initialList = listOf(aSessionData(sessionId = A_SESSION_ID.value)),
)
val presenter = createRoomListPresenter(
client = matrixClient,
announcementService = announcementService,
sessionStore = sessionStore,
)
presenter.test {
skipItems(2) // Skip initial state and intermediate event for loading the value of the feature flags
val state = awaitItem()
// Only one account, the banner is not displayed
assertThat(state.contentAsRooms().showSwipeAvatarBanner).isFalse()
sessionStore.addSession(aSessionData(sessionId = A_SESSION_ID_2.value))
assertThat(awaitItem().contentAsRooms().showSwipeAvatarBanner).isTrue()
state.eventSink(RoomListEvent.DismissSwipeAvatarBanner)
onAnnouncementDismissedResult.assertions().isCalledOnce()
.with(value(Announcement.SwipeAvatarToSwitchAccount))
// Simulate service updating the value
announcementService.emitAnnouncementsToShow(emptyList())
assertThat(awaitItem().contentAsRooms().showSwipeAvatarBanner).isFalse()
}
}
@Test
fun `present - showAllActivity and showUnreadCount reflect the feature flag values`() = runTest {
val roomList = FakeDynamicRoomList(
@@ -724,6 +770,7 @@ class RoomListPresenterTest {
announcementService: AnnouncementService = FakeAnnouncementService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
markRoomAsRead: MarkRoomAsRead? = null,
sessionStore: SessionStore = InMemorySessionStore(),
) = RoomListPresenter(
client = client,
leaveRoomPresenter = { leaveRoomState },
@@ -756,5 +803,6 @@ class RoomListPresenterTest {
announcementService = announcementService,
coldStartWatcher = FakeAnalyticsColdStartWatcher(),
featureFlagService = featureFlagService,
sessionStore = sessionStore,
)
}
@@ -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,13 +65,15 @@ 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()
eventsRecorder.assertSingle(RoomListEvent.DismissBanner)
@@ -82,18 +85,41 @@ 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()
eventsRecorder.assertSingle(RoomListEvent.DismissBanner)
}
@Test
fun `clicking on close swipe avatar banner emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomListEvent>()
setRoomListView(
state = aRoomListState(
contentState = aRoomsContentState(showSwipeAvatarBanner = true),
// Ignore the visible range updates, which are also emitted when the banner is shrinking
eventSink = { event ->
if (event !is RoomListEvent.UpdateVisibleRange) {
eventsRecorder(event)
}
},
)
)
eventsRecorder.assertEmpty()
val close = activity!!.getString(CommonStrings.action_close)
onNodeWithContentDescription(close).performClick()
eventsRecorder.assertSingle(RoomListEvent.DismissSwipeAvatarBanner)
}
@Test
fun `clicking on continue recovery key banner invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomListEvent>()
+1
View File
@@ -211,6 +211,7 @@
"banner\\.set_up_recovery\\..*",
"banner\\.battery_optimization\\..*",
"banner\\.new_sound\\..*",
"banner\\.swipe_avatar\\..*",
"full_screen_intent_banner_.*",
"screen_migration_.*",
"screen_invites_.*"