diff --git a/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt index 69def559a1..56a09a1f11 100644 --- a/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt +++ b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt @@ -19,4 +19,6 @@ sealed interface Announcement { data object NewNotificationSound : Announcement data object MultiAccount : Announcement + + data object SwipeAvatarToSwitchAccount : Announcement } diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt index 3c36989571..caf0001ee3 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt @@ -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) + } } } } diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt index 2235285bcf..4e2e11ee2e 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt @@ -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 } diff --git a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt index 18442b362d..f9df1dbaaf 100644 --- a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt +++ b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt @@ -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() } } diff --git a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/store/InMemoryAnnouncementStore.kt b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/store/InMemoryAnnouncementStore.kt index 9b5e762eb6..14a8eea9ab 100644 --- a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/store/InMemoryAnnouncementStore.kt +++ b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/store/InMemoryAnnouncementStore.kt @@ -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 } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt index 95c98a4aec..abeb7f890f 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt @@ -111,6 +111,9 @@ class HomePresenter( currentHomeNavigationBarItemOrdinal = event.item.ordinal } is HomeEvent.SwitchToAccount -> coroutineState.launch { + // The user has switched account by swiping their avatar, so they know the gesture. + // Do it before switching, since it can stop this coroutine. + announcementService.onAnnouncementDismissed(Announcement.SwipeAvatarToSwitchAccount) sessionStore.setLatestSession(event.sessionId.value) } HomeEvent.DismissMultiAccountAnnouncement -> coroutineState.launch { diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt index e799f44e25..291b9f3c00 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt @@ -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, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt index 5fc6d4c85e..68d258431d 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt @@ -253,7 +253,7 @@ private fun RoomsViewList( } } // Banner precedence (top-to-bottom): full-screen-intent > battery-optimization > - // new-notification-sound > sound-unavailable. At most one renders at a time. + // new-notification-sound > swipe-avatar > sound-unavailable. At most one renders at a time. SecurityBannerState.None -> when { state.fullScreenIntentPermissionsState.shouldDisplayBanner -> { item { @@ -272,6 +272,13 @@ private fun RoomsViewList( ) } } + state.showSwipeAvatarBanner -> { + item { + SwipeAvatarBanner( + onDismissClick = { eventSink(RoomListEvent.DismissSwipeAvatarBanner) }, + ) + } + } } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/SwipeAvatarBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/SwipeAvatarBanner.kt new file mode 100644 index 0000000000..bf1df99aec --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/SwipeAvatarBanner.kt @@ -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 = {}, + ) +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContentStatePreviewParam.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContentStatePreviewParam.kt index e24074e042..bcc369d1bc 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContentStatePreviewParam.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContentStatePreviewParam.kt @@ -30,12 +30,16 @@ open class RoomListContentStatePreviewParam : PreviewParameterProvider = 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, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvent.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvent.kt index 49d033682f..d912842b64 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvent.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvent.kt @@ -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 diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt index 68aba21188..611f1e8ee2 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt @@ -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, private val globalSearchPresenter: Presenter, private val featureFlagService: FeatureFlagService, + private val sessionStore: SessionStore, ) : Presenter { 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(), diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt index bb1ee631a1..6d3d85ab56 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt @@ -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, val seenRoomInvites: ImmutableSet, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt index cf592d9e7e..9ff324f7b8 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt @@ -28,6 +28,8 @@ import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_EXCEPTION +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.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.FakeMatrixClient @@ -39,7 +41,9 @@ 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.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -245,6 +249,36 @@ class HomePresenterTest { .with(value(Announcement.MultiAccount)) } } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `present - switching account dismisses the swipe avatar announcement`() = runTest { + val onAnnouncementDismissedResult = lambdaRecorder { } + val setLatestSessionResult = lambdaRecorder { } + val presenter = createHomePresenter( + client = FakeMatrixClient(sessionId = A_SESSION_ID), + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = A_SESSION_ID.value), + aSessionData(sessionId = A_SESSION_ID_2.value), + ), + updateUserProfileResult = { _, _, _, _ -> }, + setLatestSessionResult = setLatestSessionResult, + ), + announcementService = FakeAnnouncementService( + onAnnouncementDismissedResult = onAnnouncementDismissedResult, + ), + ) + presenter.test { + val state = expectMostRecentItem() + state.eventSink(HomeEvent.SwitchToAccount(A_SESSION_ID_2)) + runCurrent() + onAnnouncementDismissedResult.assertions().isCalledOnce() + .with(value(Announcement.SwipeAvatarToSwitchAccount)) + setLatestSessionResult.assertions().isCalledOnce() + .with(value(A_SESSION_ID_2.value)) + } + } } internal fun createHomePresenter( diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt index f73e39b8d7..f45e0fa7bf 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt @@ -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 { } + 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, ) } diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt index 3cff7ca28b..2c158d3ef9 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt @@ -94,6 +94,27 @@ class RoomListViewTest : RobolectricTest() { eventsRecorder.assertSingle(RoomListEvent.DismissBanner) } + @Test + fun `clicking on close swipe avatar banner emits the expected Event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setRoomListView( + state = aRoomListState( + contentState = aRoomsContentState(showSwipeAvatarBanner = true), + // Ignore the visible range updates + 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()