mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-10-31 08:15:02 +00:00
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:
+2
@@ -19,4 +19,6 @@ sealed interface Announcement {
|
||||
data object NewNotificationSound : Announcement
|
||||
|
||||
data object MultiAccount : Announcement
|
||||
|
||||
data object SwipeAvatarToSwitchAccount : Announcement
|
||||
}
|
||||
|
||||
+7
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -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
|
||||
}
|
||||
|
||||
+23
-5
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -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
|
||||
}
|
||||
|
||||
+10
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -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,
|
||||
|
||||
+6
-7
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
+8
-7
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+7
-3
@@ -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,
|
||||
|
||||
+135
-56
@@ -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>,
|
||||
|
||||
+43
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+40
@@ -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
|
||||
}
|
||||
}
|
||||
+5
@@ -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,
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
|
||||
+18
@@ -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(),
|
||||
|
||||
+1
@@ -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>
|
||||
|
||||
+28
-1
@@ -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,
|
||||
)
|
||||
|
||||
+48
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+35
-9
@@ -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>()
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
tests/uitests/src/test/snapshots/images/features.home.impl.components_SwipeAvatarBanner_Day_0_en.png
LFS
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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_.*"
|
||||
|
||||
Reference in New Issue
Block a user