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 NewNotificationSound : Announcement
data object MultiAccount : Announcement data object MultiAccount : Announcement
data object SwipeAvatarToSwitchAccount : Announcement
} }
@@ -34,6 +34,7 @@ class DefaultAnnouncementService(
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show) announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show)
} }
Announcement.MultiAccount -> showAnnouncementIfNeverShown(announcement) Announcement.MultiAccount -> showAnnouncementIfNeverShown(announcement)
Announcement.SwipeAvatarToSwitchAccount -> showAnnouncementIfNeverShown(announcement)
} }
} }
@@ -46,7 +47,8 @@ class DefaultAnnouncementService(
flowOf(Unit), flowOf(Unit),
announcementStore.announcementStatusFlow(Announcement.NewNotificationSound), announcementStore.announcementStatusFlow(Announcement.NewNotificationSound),
announcementStore.announcementStatusFlow(Announcement.MultiAccount), announcementStore.announcementStatusFlow(Announcement.MultiAccount),
) { _, newNotificationSoundStatus, multiAccountStatus -> announcementStore.announcementStatusFlow(Announcement.SwipeAvatarToSwitchAccount),
) { _, newNotificationSoundStatus, multiAccountStatus, swipeAvatarToSwitchAccountStatus ->
buildList { buildList {
if (newNotificationSoundStatus == AnnouncementStatus.Show) { if (newNotificationSoundStatus == AnnouncementStatus.Show) {
add(Announcement.NewNotificationSound) add(Announcement.NewNotificationSound)
@@ -56,6 +58,10 @@ class DefaultAnnouncementService(
if (multiAccountStatus != AnnouncementStatus.Shown) { if (multiAccountStatus != AnnouncementStatus.Shown) {
add(Announcement.MultiAccount) 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 spaceAnnouncementKey = intPreferencesKey("spaceAnnouncement")
private val newNotificationSoundKey = intPreferencesKey("newNotificationSound") private val newNotificationSoundKey = intPreferencesKey("newNotificationSound")
private val multiAccountKey = intPreferencesKey("multiAccountAnnouncement") private val multiAccountKey = intPreferencesKey("multiAccountAnnouncement")
private val swipeAvatarToSwitchAccountKey = intPreferencesKey("swipeAvatarToSwitchAccountAnnouncement")
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultAnnouncementStore( class DefaultAnnouncementStore(
@@ -42,10 +43,13 @@ class DefaultAnnouncementStore(
// - NewNotificationSound is opted in by AppMigration08 // - NewNotificationSound is opted in by AppMigration08
// - MultiAccount is NeverShown by default, which means pending. It is set to Shown when the user enters // - 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. // 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) { val defaultStatus = when (announcement) {
Announcement.Fullscreen.Space -> AnnouncementStatus.Shown Announcement.Fullscreen.Space -> AnnouncementStatus.Shown
Announcement.NewNotificationSound -> AnnouncementStatus.Shown Announcement.NewNotificationSound -> AnnouncementStatus.Shown
Announcement.MultiAccount -> AnnouncementStatus.NeverShown Announcement.MultiAccount -> AnnouncementStatus.NeverShown
Announcement.SwipeAvatarToSwitchAccount -> AnnouncementStatus.NeverShown
} }
return store.data.map { prefs -> return store.data.map { prefs ->
val ordinal = prefs[key] ?: defaultStatus.ordinal val ordinal = prefs[key] ?: defaultStatus.ordinal
@@ -62,4 +66,5 @@ private fun Announcement.toKey() = when (this) {
Announcement.Fullscreen.Space -> spaceAnnouncementKey Announcement.Fullscreen.Space -> spaceAnnouncementKey
Announcement.NewNotificationSound -> newNotificationSoundKey Announcement.NewNotificationSound -> newNotificationSoundKey
Announcement.MultiAccount -> multiAccountKey Announcement.MultiAccount -> multiAccountKey
Announcement.SwipeAvatarToSwitchAccount -> swipeAvatarToSwitchAccountKey
} }
@@ -67,6 +67,22 @@ class DefaultAnnouncementServiceTest {
assertThat(announcementStore.announcementStatusFlow(Announcement.MultiAccount).first()).isEqualTo(AnnouncementStatus.Shown) 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 @Test
fun `test announcementsToShowFlow`() = runTest { fun `test announcementsToShowFlow`() = runTest {
val announcementStore = InMemoryAnnouncementStore() val announcementStore = InMemoryAnnouncementStore()
@@ -74,16 +90,18 @@ class DefaultAnnouncementServiceTest {
announcementStore = announcementStore, announcementStore = announcementStore,
) )
sut.announcementsToShowFlow().test { sut.announcementsToShowFlow().test {
// MultiAccount is pending by default // MultiAccount and SwipeAvatarToSwitchAccount are pending by default
assertThat(awaitItem()).containsExactly(Announcement.MultiAccount) assertThat(awaitItem()).containsExactly(Announcement.MultiAccount, Announcement.SwipeAvatarToSwitchAccount)
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show) 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) announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Show)
// Still pending // Still pending
assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount) assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount, Announcement.SwipeAvatarToSwitchAccount)
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Shown) announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Shown)
assertThat(awaitItem()).containsExactly(Announcement.MultiAccount) assertThat(awaitItem()).containsExactly(Announcement.MultiAccount, Announcement.SwipeAvatarToSwitchAccount)
announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Shown) announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Shown)
assertThat(awaitItem()).containsExactly(Announcement.SwipeAvatarToSwitchAccount)
announcementStore.setAnnouncementStatus(Announcement.SwipeAvatarToSwitchAccount, AnnouncementStatus.Shown)
assertThat(awaitItem()).isEmpty() assertThat(awaitItem()).isEmpty()
} }
} }
@@ -17,10 +17,12 @@ class InMemoryAnnouncementStore(
initialFullscreenAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, initialFullscreenAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
initialNewNotificationSoundAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, initialNewNotificationSoundAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
initialMultiAccountAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, initialMultiAccountAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
initialSwipeAvatarToSwitchAccountAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
) : AnnouncementStore { ) : AnnouncementStore {
private val fullScreenAnnouncement = MutableStateFlow(initialFullscreenAnnouncementStatus) private val fullScreenAnnouncement = MutableStateFlow(initialFullscreenAnnouncementStatus)
private val newNotificationSoundAnnouncement = MutableStateFlow(initialNewNotificationSoundAnnouncementStatus) private val newNotificationSoundAnnouncement = MutableStateFlow(initialNewNotificationSoundAnnouncementStatus)
private val multiAccountAnnouncement = MutableStateFlow(initialMultiAccountAnnouncementStatus) private val multiAccountAnnouncement = MutableStateFlow(initialMultiAccountAnnouncementStatus)
private val swipeAvatarToSwitchAccountAnnouncement = MutableStateFlow(initialSwipeAvatarToSwitchAccountAnnouncementStatus)
override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStatus) { override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStatus) {
announcement.toMutableStateFlow().value = status announcement.toMutableStateFlow().value = status
@@ -34,11 +36,13 @@ class InMemoryAnnouncementStore(
fullScreenAnnouncement.value = AnnouncementStatus.NeverShown fullScreenAnnouncement.value = AnnouncementStatus.NeverShown
newNotificationSoundAnnouncement.value = AnnouncementStatus.NeverShown newNotificationSoundAnnouncement.value = AnnouncementStatus.NeverShown
multiAccountAnnouncement.value = AnnouncementStatus.NeverShown multiAccountAnnouncement.value = AnnouncementStatus.NeverShown
swipeAvatarToSwitchAccountAnnouncement.value = AnnouncementStatus.NeverShown
} }
private fun Announcement.toMutableStateFlow() = when (this) { private fun Announcement.toMutableStateFlow() = when (this) {
is Announcement.Fullscreen -> fullScreenAnnouncement is Announcement.Fullscreen -> fullScreenAnnouncement
Announcement.NewNotificationSound -> newNotificationSoundAnnouncement Announcement.NewNotificationSound -> newNotificationSoundAnnouncement
Announcement.MultiAccount -> multiAccountAnnouncement Announcement.MultiAccount -> multiAccountAnnouncement
Announcement.SwipeAvatarToSwitchAccount -> swipeAvatarToSwitchAccountAnnouncement
} }
} }
@@ -11,4 +11,5 @@ package io.element.android.features.home.impl
sealed interface HomeEvent { sealed interface HomeEvent {
data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvent data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvent
data object DismissMultiAccountAnnouncement : 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.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState 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.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.indicator.api.IndicatorService 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.matrix.api.sync.SyncService
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@@ -56,13 +58,15 @@ class HomePresenter(
private val sessionStore: SessionStore, private val sessionStore: SessionStore,
private val announcementService: AnnouncementService, private val announcementService: AnnouncementService,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
@AppCoroutineScope
private val appCoroutineScope: CoroutineScope,
) : Presenter<HomeState> { ) : Presenter<HomeState> {
private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder() private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder()
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
@Composable @Composable
override fun present(): HomeState { override fun present(): HomeState {
val coroutineState = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val matrixUser by client.userProfile.collectAsState() val matrixUser by client.userProfile.collectAsState()
val totalUnreadCount by produceState(client.totalUnreadCount().getOrDefault(0).toInt()) { val totalUnreadCount by produceState(client.totalUnreadCount().getOrDefault(0).toInt()) {
@@ -126,7 +130,11 @@ class HomePresenter(
is HomeEvent.SelectHomeNavigationBarItem -> { is HomeEvent.SelectHomeNavigationBarItem -> {
currentHomeNavigationBarItemOrdinal = event.item.ordinal 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) announcementService.onAnnouncementDismissed(Announcement.MultiAccount)
} }
} }
@@ -217,7 +217,11 @@ private fun HomeScaffold(
}, },
onMenuActionClick = onMenuActionClick, onMenuActionClick = onMenuActionClick,
onOpenSettings = onOpenSettings, 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, scrollBehavior = scrollBehavior,
displayFilters = state.displayRoomListFilters, displayFilters = state.displayRoomListFilters,
filtersState = roomListState.filtersState, 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.components.AnnouncementType
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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 @Composable
internal fun BatteryOptimizationBanner( internal fun BatteryOptimizationBanner(
state: BatteryOptimizationState, onActionClick: () -> Unit,
onDismissClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Announcement( Announcement(
@@ -31,8 +29,8 @@ internal fun BatteryOptimizationBanner(
description = stringResource(R.string.banner_battery_optimization_content_android), description = stringResource(R.string.banner_battery_optimization_content_android),
type = AnnouncementType.Actionable( type = AnnouncementType.Actionable(
actionText = stringResource(R.string.banner_battery_optimization_submit_android), actionText = stringResource(R.string.banner_battery_optimization_submit_android),
onActionClick = { state.eventSink(BatteryOptimizationEvent.RequestDisableOptimizations) }, onActionClick = onActionClick,
onDismissClick = { state.eventSink(BatteryOptimizationEvent.Dismiss) }, onDismissClick = onDismissClick,
), ),
) )
} }
@@ -41,6 +39,7 @@ internal fun BatteryOptimizationBanner(
@Composable @Composable
internal fun BatteryOptimizationBannerPreview() = ElementPreview { internal fun BatteryOptimizationBannerPreview() = ElementPreview {
BatteryOptimizationBanner( 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.components.AnnouncementType
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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 import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun FullScreenIntentPermissionBanner( fun FullScreenIntentPermissionBanner(
state: FullScreenIntentPermissionsState, onActionClick: () -> Unit,
onDismissClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Announcement( Announcement(
@@ -31,8 +29,8 @@ fun FullScreenIntentPermissionBanner(
description = stringResource(R.string.full_screen_intent_banner_message), description = stringResource(R.string.full_screen_intent_banner_message),
type = AnnouncementType.Actionable( type = AnnouncementType.Actionable(
actionText = stringResource(CommonStrings.action_continue), actionText = stringResource(CommonStrings.action_continue),
onDismissClick = { state.eventSink(FullScreenIntentPermissionsEvent.Dismiss) }, onDismissClick = onDismissClick,
onActionClick = { state.eventSink(FullScreenIntentPermissionsEvent.OpenSettings) }, onActionClick = onActionClick,
), ),
modifier = modifier.roomListBannerPadding(), modifier = modifier.roomListBannerPadding(),
) )
@@ -42,6 +40,9 @@ fun FullScreenIntentPermissionBanner(
@Composable @Composable
internal fun FullScreenIntentPermissionBannerPreview() { internal fun FullScreenIntentPermissionBannerPreview() {
ElementPreview { 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.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.filter
@Composable @Composable
fun HomeTopBar( fun HomeTopBar(
@@ -307,9 +308,12 @@ private fun NavigationIcon(
// Listen to page changes and switch account if needed // Listen to page changes and switch account if needed
val latestOnAccountSwitch by rememberUpdatedState(onAccountSwitch) val latestOnAccountSwitch by rememberUpdatedState(onAccountSwitch)
LaunchedEffect(pagerState) { LaunchedEffect(pagerState) {
snapshotFlow { pagerState.settledPage }.collect { page -> snapshotFlow { pagerState.settledPage }
latestOnAccountSwitch(SessionId(currentUserAndNeighbors[page].userId.value)) // 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( VerticalPager(
state = pagerState, state = pagerState,
@@ -8,6 +8,12 @@
package io.element.android.features.home.impl.components 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
@@ -24,6 +32,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter 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.filters.selection.FilterSelectionState
import io.element.android.features.home.impl.model.RoomListRoomSummary 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.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.RoomListContentState
import io.element.android.features.home.impl.roomlist.RoomListContentStatePreviewParam 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.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.SpaceFiltersState
import io.element.android.features.home.impl.spacefilters.anUnselectedSpaceFiltersState import io.element.android.features.home.impl.spacefilters.anUnselectedSpaceFiltersState
import io.element.android.libraries.designsystem.preview.ElementPreview 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.IconSource
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect 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 io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -158,22 +170,20 @@ private fun EmptyView(
}, },
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
) )
Box { RoomListBannerContainer(
when (state.securityBannerState) { banner = state.securityBannerState.toRoomListBanner(),
SecurityBannerState.SetUpRecovery -> { ) { banner ->
SetUpRecoveryKeyBanner( RoomListBannerView(
onContinueClick = onSetUpRecoveryClick, banner = banner,
onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, onActionClick = {
) when (banner) {
} RoomListBanner.SetUpRecovery -> onSetUpRecoveryClick()
SecurityBannerState.RecoveryKeyConfirmation -> { RoomListBanner.RecoveryKeyConfirmation -> onConfirmRecoveryKeyClick()
ConfirmRecoveryKeyBanner( else -> Unit
onContinueClick = onConfirmRecoveryKeyClick, }
onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, },
) onDismissClick = { eventSink(RoomListEvent.DismissBanner) },
} )
SecurityBannerState.None -> Unit
}
} }
} }
} }
@@ -227,54 +237,56 @@ private fun RoomsViewList(
lazyListState: LazyListState, lazyListState: LazyListState,
modifier: Modifier = Modifier, 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 -> 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( LazyColumn(
state = lazyListState, state = lazyListState,
modifier = modifier, modifier = modifier,
contentPadding = contentPadding, contentPadding = contentPadding,
) { ) {
when (state.securityBannerState) { // The banner slot is always the first item of the list, even when there is no banner to display,
SecurityBannerState.SetUpRecovery -> { // so that the rooms keep the same index when a banner is added or removed. Since the rooms do not
item { // have a key, this is required to keep the scroll position.
SetUpRecoveryKeyBanner( item {
onContinueClick = onSetUpRecoveryClick, RoomListBannerContainer(
onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, banner = banner,
) modifier = Modifier.heightIn(min = bannerSlotMinHeight),
} ) { displayedBanner ->
} RoomListBannerView(
SecurityBannerState.RecoveryKeyConfirmation -> { banner = displayedBanner,
item { onActionClick = {
ConfirmRecoveryKeyBanner( when (displayedBanner) {
onContinueClick = onConfirmRecoveryKeyClick, RoomListBanner.SetUpRecovery -> onSetUpRecoveryClick()
onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, RoomListBanner.RecoveryKeyConfirmation -> onConfirmRecoveryKeyClick()
) RoomListBanner.FullScreenIntentPermission ->
} state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvent.OpenSettings)
} RoomListBanner.BatteryOptimization ->
// Banner precedence (top-to-bottom): full-screen-intent > battery-optimization > state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.RequestDisableOptimizations)
// new-notification-sound > sound-unavailable. At most one renders at a time. RoomListBanner.NewNotificationSound,
SecurityBannerState.None -> when { RoomListBanner.SwipeAvatar -> Unit
state.fullScreenIntentPermissionsState.shouldDisplayBanner -> { }
item { },
FullScreenIntentPermissionBanner(state = state.fullScreenIntentPermissionsState) onDismissClick = {
} when (displayedBanner) {
} RoomListBanner.SetUpRecovery,
state.batteryOptimizationState.shouldDisplayBanner -> { RoomListBanner.RecoveryKeyConfirmation -> eventSink(RoomListEvent.DismissBanner)
item { RoomListBanner.FullScreenIntentPermission ->
BatteryOptimizationBanner(state = state.batteryOptimizationState) state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvent.Dismiss)
} RoomListBanner.BatteryOptimization -> state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.Dismiss)
} RoomListBanner.NewNotificationSound -> eventSink(RoomListEvent.DismissNewNotificationSoundBanner)
state.showNewNotificationSoundBanner -> { RoomListBanner.SwipeAvatar -> eventSink(RoomListEvent.DismissSwipeAvatarBanner)
item { }
NewNotificationSoundBanner( },
onDismissClick = { eventSink(RoomListEvent.DismissNewNotificationSoundBanner) }, )
)
}
}
} }
} }
// Note: do not use a key for the LazyColumn, or the scroll will not behave as expected if a room // 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. // is moved to the top of the list.
itemsIndexed( 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 @Composable
private fun EmptyViewForFilterStates( private fun EmptyViewForFilterStates(
selectedFilters: ImmutableList<RoomListFilter>, 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( aRoomsContentState(
showNewNotificationSoundBanner = true, showNewNotificationSoundBanner = true,
), ),
aRoomsContentState(
showSwipeAvatarBanner = true,
),
) )
} }
internal fun aRoomsContentState( internal fun aRoomsContentState(
securityBannerState: SecurityBannerState = SecurityBannerState.None, securityBannerState: SecurityBannerState = SecurityBannerState.None,
showNewNotificationSoundBanner: Boolean = false, showNewNotificationSoundBanner: Boolean = false,
showSwipeAvatarBanner: Boolean = false,
showUnreadCount: Boolean = false, showUnreadCount: Boolean = false,
summaries: ImmutableList<RoomListRoomSummary> = aRoomListRoomSummaryList(), summaries: ImmutableList<RoomListRoomSummary> = aRoomListRoomSummaryList(),
fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(), fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(),
@@ -45,6 +49,7 @@ internal fun aRoomsContentState(
) = RoomListContentState.Rooms( ) = RoomListContentState.Rooms(
securityBannerState = securityBannerState, securityBannerState = securityBannerState,
showNewNotificationSoundBanner = showNewNotificationSoundBanner, showNewNotificationSoundBanner = showNewNotificationSoundBanner,
showSwipeAvatarBanner = showSwipeAvatarBanner,
showUnreadCount = showUnreadCount, showUnreadCount = showUnreadCount,
fullScreenIntentPermissionsState = fullScreenIntentPermissionsState, fullScreenIntentPermissionsState = fullScreenIntentPermissionsState,
batteryOptimizationState = batteryOptimizationState, batteryOptimizationState = batteryOptimizationState,
@@ -16,6 +16,7 @@ sealed interface RoomListEvent {
data object DismissRequestVerificationPrompt : RoomListEvent data object DismissRequestVerificationPrompt : RoomListEvent
data object DismissBanner : RoomListEvent data object DismissBanner : RoomListEvent
data object DismissNewNotificationSoundBanner : RoomListEvent data object DismissNewNotificationSoundBanner : RoomListEvent
data object DismissSwipeAvatarBanner : RoomListEvent
data object ToggleSearchResults : RoomListEvent data object ToggleSearchResults : RoomListEvent
data class ShowContextMenu(val roomSummary: RoomListRoomSummary) : 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.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar
import io.element.android.libraries.push.api.battery.BatteryOptimizationState 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.AnalyticsService
import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction 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.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@@ -89,6 +91,7 @@ class RoomListPresenter(
private val spaceFiltersPresenter: Presenter<SpaceFiltersState>, private val spaceFiltersPresenter: Presenter<SpaceFiltersState>,
private val globalSearchPresenter: Presenter<GlobalSearchState>, private val globalSearchPresenter: Presenter<GlobalSearchState>,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val sessionStore: SessionStore,
) : Presenter<RoomListState> { ) : Presenter<RoomListState> {
private val encryptionService = client.encryptionService private val encryptionService = client.encryptionService
@@ -112,6 +115,15 @@ class RoomListPresenter(
announcements.contains(Announcement.NewNotificationSound) announcements.contains(Announcement.NewNotificationSound)
} }
}.collectAsState(false) }.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 // Avatar indicator
val hideInvitesAvatar by client.rememberHideInvitesAvatar() val hideInvitesAvatar by client.rememberHideInvitesAvatar()
@@ -133,6 +145,9 @@ class RoomListPresenter(
RoomListEvent.DismissNewNotificationSoundBanner -> coroutineScope.launch { RoomListEvent.DismissNewNotificationSoundBanner -> coroutineScope.launch {
announcementService.onAnnouncementDismissed(Announcement.NewNotificationSound) announcementService.onAnnouncementDismissed(Announcement.NewNotificationSound)
} }
RoomListEvent.DismissSwipeAvatarBanner -> coroutineScope.launch {
announcementService.onAnnouncementDismissed(Announcement.SwipeAvatarToSwitchAccount)
}
RoomListEvent.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvent.ToggleSearchVisibility) RoomListEvent.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvent.ToggleSearchVisibility)
is RoomListEvent.ShowContextMenu -> { is RoomListEvent.ShowContextMenu -> {
coroutineScope.showContextMenu(event, contextMenu) coroutineScope.showContextMenu(event, contextMenu)
@@ -176,6 +191,7 @@ class RoomListPresenter(
val contentState = roomListContentState( val contentState = roomListContentState(
securityBannerDismissed, securityBannerDismissed,
showNewNotificationSoundBanner, showNewNotificationSoundBanner,
showSwipeAvatarBanner,
showUnreadCount, showUnreadCount,
showAllActivity, showAllActivity,
) )
@@ -235,6 +251,7 @@ class RoomListPresenter(
private fun roomListContentState( private fun roomListContentState(
securityBannerDismissed: Boolean, securityBannerDismissed: Boolean,
showNewNotificationSoundBanner: Boolean, showNewNotificationSoundBanner: Boolean,
showSwipeAvatarBanner: Boolean,
showUnreadCount: Boolean, showUnreadCount: Boolean,
showAllActivity: Boolean, showAllActivity: Boolean,
): RoomListContentState { ): RoomListContentState {
@@ -265,6 +282,7 @@ class RoomListPresenter(
RoomListContentState.Rooms( RoomListContentState.Rooms(
securityBannerState = securityBannerState, securityBannerState = securityBannerState,
showNewNotificationSoundBanner = showNewNotificationSoundBanner, showNewNotificationSoundBanner = showNewNotificationSoundBanner,
showSwipeAvatarBanner = showSwipeAvatarBanner,
showUnreadCount = showUnreadCount, showUnreadCount = showUnreadCount,
fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(), fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(),
batteryOptimizationState = batteryOptimizationPresenter.present(), batteryOptimizationState = batteryOptimizationPresenter.present(),
@@ -73,6 +73,7 @@ sealed interface RoomListContentState {
val fullScreenIntentPermissionsState: FullScreenIntentPermissionsState, val fullScreenIntentPermissionsState: FullScreenIntentPermissionsState,
val batteryOptimizationState: BatteryOptimizationState, val batteryOptimizationState: BatteryOptimizationState,
val showNewNotificationSoundBanner: Boolean, val showNewNotificationSoundBanner: Boolean,
val showSwipeAvatarBanner: Boolean,
val showUnreadCount: Boolean, val showUnreadCount: Boolean,
val summaries: ImmutableList<RoomListRoomSummary>, val summaries: ImmutableList<RoomListRoomSummary>,
val seenRoomInvites: ImmutableSet<RoomId>, 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_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_submit">"Get recovery key"</string>
<string name="banner_set_up_recovery_title">"Back up your chats"</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_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_primary_button_title">"Enter your recovery key"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Forgot 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.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@@ -119,6 +123,7 @@ class HomePresenterTest {
} }
} }
@OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `present - total unread count is updated on sync heartbeat`() = runTest { fun `present - total unread count is updated on sync heartbeat`() = runTest {
var unreadCount = 0L var unreadCount = 0L
@@ -136,6 +141,7 @@ class HomePresenterTest {
} }
} }
@OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `present - total unread count defaults to 0 when the SDK call fails`() = runTest { fun `present - total unread count defaults to 0 when the SDK call fails`() = runTest {
val syncService = FakeSyncService() 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 @Test
fun `present - total unread count is loaded in the initial state`() = runTest { fun `present - total unread count is loaded in the initial state`() = runTest {
val presenter = createHomePresenter( val presenter = createHomePresenter(
@@ -291,7 +316,7 @@ class HomePresenterTest {
} }
} }
internal fun createHomePresenter( internal fun TestScope.createHomePresenter(
client: MatrixClient = FakeMatrixClient(), client: MatrixClient = FakeMatrixClient(),
syncService: SyncService = FakeSyncService(), syncService: SyncService = FakeSyncService(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
@@ -301,6 +326,7 @@ internal fun createHomePresenter(
sessionStore: SessionStore = InMemorySessionStore(), sessionStore: SessionStore = InMemorySessionStore(),
announcementService: AnnouncementService = FakeAnnouncementService(), announcementService: AnnouncementService = FakeAnnouncementService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(), featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
appCoroutineScope: CoroutineScope = backgroundScope,
) = HomePresenter( ) = HomePresenter(
client = client, client = client,
syncService = syncService, syncService = syncService,
@@ -312,4 +338,5 @@ internal fun createHomePresenter(
sessionStore = sessionStore, sessionStore = sessionStore,
announcementService = announcementService, announcementService = announcementService,
featureFlagService = featureFlagService, 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_2
import io.element.android.libraries.matrix.test.A_ROOM_ID_3 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
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.FakeMatrixClient
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService 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.battery.aBatteryOptimizationState
import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner 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.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher 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 @Test
fun `present - showAllActivity and showUnreadCount reflect the feature flag values`() = runTest { fun `present - showAllActivity and showUnreadCount reflect the feature flag values`() = runTest {
val roomList = FakeDynamicRoomList( val roomList = FakeDynamicRoomList(
@@ -724,6 +770,7 @@ class RoomListPresenterTest {
announcementService: AnnouncementService = FakeAnnouncementService(), announcementService: AnnouncementService = FakeAnnouncementService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(), featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
markRoomAsRead: MarkRoomAsRead? = null, markRoomAsRead: MarkRoomAsRead? = null,
sessionStore: SessionStore = InMemorySessionStore(),
) = RoomListPresenter( ) = RoomListPresenter(
client = client, client = client,
leaveRoomPresenter = { leaveRoomState }, leaveRoomPresenter = { leaveRoomState },
@@ -756,5 +803,6 @@ class RoomListPresenterTest {
announcementService = announcementService, announcementService = announcementService,
coldStartWatcher = FakeAnalyticsColdStartWatcher(), coldStartWatcher = FakeAnalyticsColdStartWatcher(),
featureFlagService = featureFlagService, 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( eventsRecorder.assertList(
listOf( listOf(
RoomListEvent.UpdateVisibleRange(0..5), RoomListEvent.UpdateVisibleRange(0..4),
) )
) )
} }
@@ -64,13 +65,15 @@ class RoomListViewTest : RobolectricTest() {
setRoomListView( setRoomListView(
state = aRoomListState( state = aRoomListState(
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation), 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) val close = activity!!.getString(CommonStrings.action_close)
onNodeWithContentDescription(close).performClick() onNodeWithContentDescription(close).performClick()
eventsRecorder.assertSingle(RoomListEvent.DismissBanner) eventsRecorder.assertSingle(RoomListEvent.DismissBanner)
@@ -82,18 +85,41 @@ class RoomListViewTest : RobolectricTest() {
setRoomListView( setRoomListView(
state = aRoomListState( state = aRoomListState(
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery), 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) val close = activity!!.getString(CommonStrings.action_close)
onNodeWithContentDescription(close).performClick() onNodeWithContentDescription(close).performClick()
eventsRecorder.assertSingle(RoomListEvent.DismissBanner) 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 @Test
fun `clicking on continue recovery key banner invokes the expected callback`() = runAndroidComposeUiTest { fun `clicking on continue recovery key banner invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomListEvent>() val eventsRecorder = EventsRecorder<RoomListEvent>()
+1
View File
@@ -211,6 +211,7 @@
"banner\\.set_up_recovery\\..*", "banner\\.set_up_recovery\\..*",
"banner\\.battery_optimization\\..*", "banner\\.battery_optimization\\..*",
"banner\\.new_sound\\..*", "banner\\.new_sound\\..*",
"banner\\.swipe_avatar\\..*",
"full_screen_intent_banner_.*", "full_screen_intent_banner_.*",
"screen_migration_.*", "screen_migration_.*",
"screen_invites_.*" "screen_invites_.*"