From 259fc222da618d9da9abae9106e7e526c242f60a Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 13 Oct 2026 09:45:23 +0200 Subject: [PATCH 1/9] Import strings for the swipe account banner --- features/home/impl/src/main/res/values/localazy.xml | 2 ++ tools/localazy/config.json | 1 + 2 files changed, 3 insertions(+) diff --git a/features/home/impl/src/main/res/values/localazy.xml b/features/home/impl/src/main/res/values/localazy.xml index 4f3420fa23..d1983621ac 100644 --- a/features/home/impl/src/main/res/values/localazy.xml +++ b/features/home/impl/src/main/res/values/localazy.xml @@ -8,6 +8,8 @@ "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." "Get recovery key" "Back up your chats" + "Swipe up or down on your avatar to switch between your accounts without going to the app settings." + "Swipe your avatar to switch accounts" "Confirm your recovery key to maintain access to your key storage and message history." "Enter your recovery key" "Forgot your recovery key?" diff --git a/tools/localazy/config.json b/tools/localazy/config.json index a46ae3adc1..c4e53a55c2 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -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_.*" From 14710599a72566a77300175899f59844cc2945b1 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 13 Oct 2026 10:06:43 +0200 Subject: [PATCH 2/9] Announce the swipe gesture on the account avatar Display a banner on the room list when 2 or more accounts are logged in, to let the user know that they can swipe their avatar to switch account. The banner is stored as a new Announcement.SwipeAvatarToSwitchAccount, and is dismissed when the user taps on it, or when the user switches account by swiping their avatar. The banner is displayed at the same place as the new notification sound banner, but never at the same time. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../features/announcement/api/Announcement.kt | 2 + .../impl/DefaultAnnouncementService.kt | 8 +++- .../impl/store/DefaultAnnouncementStore.kt | 5 ++ .../impl/DefaultAnnouncementServiceTest.kt | 28 +++++++++-- .../impl/store/InMemoryAnnouncementStore.kt | 4 ++ .../features/home/impl/HomePresenter.kt | 3 ++ .../home/impl/components/HomeTopBar.kt | 10 ++-- .../impl/components/RoomListContentView.kt | 9 +++- .../home/impl/components/SwipeAvatarBanner.kt | 43 +++++++++++++++++ .../RoomListContentStatePreviewParam.kt | 5 ++ .../home/impl/roomlist/RoomListEvent.kt | 1 + .../home/impl/roomlist/RoomListPresenter.kt | 18 +++++++ .../home/impl/roomlist/RoomListState.kt | 1 + .../features/home/impl/HomePresenterTest.kt | 34 +++++++++++++ .../impl/roomlist/RoomListPresenterTest.kt | 48 +++++++++++++++++++ .../home/impl/roomlist/RoomListViewTest.kt | 21 ++++++++ 16 files changed, 230 insertions(+), 10 deletions(-) create mode 100644 features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/SwipeAvatarBanner.kt 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() From 8923ba1e1b83b6ed13f284b1022e59e9c943811c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 13 Oct 2026 10:36:13 +0200 Subject: [PATCH 3/9] Animate the dismissal of the banners in the room list Add DismissibleBanner, which makes a banner shrink and fade out when it is dismissed, and invokes the dismiss callback when the animation is over, so that the banner is removed from the list when its height is 0. Apply it to all the dismissible banners of the room list. The banners are now rendered in a slot which is always the first item of the room list, so that the rooms, which do not have a key, keep the same index and the scroll position is preserved when a banner is removed. The visible range of rooms sent to the SDK does not include this slot anymore. FullScreenIntentPermissionBanner and BatteryOptimizationBanner now take callbacks instead of their state, like the other banners. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../components/BatteryOptimizationBanner.kt | 13 +- .../home/impl/components/DismissibleBanner.kt | 78 +++++++++ .../FullScreenIntentPermissionBanner.kt | 15 +- .../impl/components/RoomListContentView.kt | 159 ++++++++++++------ .../home/impl/roomlist/RoomListViewTest.kt | 31 ++-- 5 files changed, 217 insertions(+), 79 deletions(-) create mode 100644 features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/DismissibleBanner.kt diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BatteryOptimizationBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BatteryOptimizationBanner.kt index 95826665fe..3feabca8b0 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BatteryOptimizationBanner.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BatteryOptimizationBanner.kt @@ -16,13 +16,11 @@ import io.element.android.libraries.designsystem.components.Announcement import io.element.android.libraries.designsystem.components.AnnouncementType import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.push.api.battery.BatteryOptimizationEvent -import io.element.android.libraries.push.api.battery.BatteryOptimizationState -import io.element.android.libraries.push.api.battery.aBatteryOptimizationState @Composable internal fun BatteryOptimizationBanner( - state: BatteryOptimizationState, + onActionClick: () -> Unit, + onDismissClick: () -> Unit, modifier: Modifier = Modifier, ) { Announcement( @@ -31,8 +29,8 @@ internal fun BatteryOptimizationBanner( description = stringResource(R.string.banner_battery_optimization_content_android), type = AnnouncementType.Actionable( actionText = stringResource(R.string.banner_battery_optimization_submit_android), - onActionClick = { state.eventSink(BatteryOptimizationEvent.RequestDisableOptimizations) }, - onDismissClick = { state.eventSink(BatteryOptimizationEvent.Dismiss) }, + onActionClick = onActionClick, + onDismissClick = onDismissClick, ), ) } @@ -41,6 +39,7 @@ internal fun BatteryOptimizationBanner( @Composable internal fun BatteryOptimizationBannerPreview() = ElementPreview { BatteryOptimizationBanner( - state = aBatteryOptimizationState(), + onActionClick = {}, + onDismissClick = {}, ) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/DismissibleBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/DismissibleBanner.kt new file mode 100644 index 0000000000..1c743ec8dd --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/DismissibleBanner.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import kotlinx.coroutines.flow.first + +/** + * Wraps a banner of the room list so that it shrinks and fades out when it is dismissed. + * [onDismiss] is invoked once the animation is over, so that the banner is removed from the list + * when its height is 0, and the items below it are not moved abruptly. + * + * @param onDismiss invoked when the banner has been dismissed and the animation is over. + * @param modifier the modifier to apply to the banner container. + * @param content the banner, which must invoke the provided lambda when the user dismisses it. + */ +@Composable +internal fun DismissibleBanner( + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable (onDismissClick: () -> Unit) -> Unit, +) { + val visibleState = remember { MutableTransitionState(true) } + val latestOnDismiss by rememberUpdatedState(onDismiss) + var isDismissNotified by remember { mutableStateOf(false) } + fun notifyDismiss() { + if (!isDismissNotified) { + isDismissNotified = true + latestOnDismiss() + } + } + LaunchedEffect(visibleState) { + snapshotFlow { visibleState.isIdle && !visibleState.currentState }.first { it } + notifyDismiss() + } + DisposableEffect(visibleState) { + onDispose { + // The item can be disposed during the animation, for instance if the user scrolls the list + if (!visibleState.targetState) { + notifyDismiss() + } + } + } + AnimatedVisibility( + visibleState = visibleState, + modifier = modifier, + enter = EnterTransition.None, + exit = shrinkVertically( + animationSpec = tween(), + shrinkTowards = Alignment.CenterVertically, + ) + fadeOut( + animationSpec = tween(), + ), + ) { + content { visibleState.targetState = false } + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/FullScreenIntentPermissionBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/FullScreenIntentPermissionBanner.kt index f2826415d2..ae5a25a623 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/FullScreenIntentPermissionBanner.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/FullScreenIntentPermissionBanner.kt @@ -16,14 +16,12 @@ import io.element.android.libraries.designsystem.components.Announcement import io.element.android.libraries.designsystem.components.AnnouncementType import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvent -import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState -import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState import io.element.android.libraries.ui.strings.CommonStrings @Composable fun FullScreenIntentPermissionBanner( - state: FullScreenIntentPermissionsState, + onActionClick: () -> Unit, + onDismissClick: () -> Unit, modifier: Modifier = Modifier ) { Announcement( @@ -31,8 +29,8 @@ fun FullScreenIntentPermissionBanner( description = stringResource(R.string.full_screen_intent_banner_message), type = AnnouncementType.Actionable( actionText = stringResource(CommonStrings.action_continue), - onDismissClick = { state.eventSink(FullScreenIntentPermissionsEvent.Dismiss) }, - onActionClick = { state.eventSink(FullScreenIntentPermissionsEvent.OpenSettings) }, + onDismissClick = onDismissClick, + onActionClick = onActionClick, ), modifier = modifier.roomListBannerPadding(), ) @@ -42,6 +40,9 @@ fun FullScreenIntentPermissionBanner( @Composable internal fun FullScreenIntentPermissionBannerPreview() { ElementPreview { - FullScreenIntentPermissionBanner(aFullScreenIntentPermissionsState()) + FullScreenIntentPermissionBanner( + onActionClick = {}, + onDismissClick = {}, + ) } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt index 68d258431d..874f4f4784 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt @@ -52,6 +52,8 @@ import io.element.android.libraries.designsystem.theme.components.HorizontalDivi import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect +import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvent +import io.element.android.libraries.push.api.battery.BatteryOptimizationEvent import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList @@ -161,16 +163,24 @@ private fun EmptyView( Box { when (state.securityBannerState) { SecurityBannerState.SetUpRecovery -> { - SetUpRecoveryKeyBanner( - onContinueClick = onSetUpRecoveryClick, - onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, - ) + DismissibleBanner( + onDismiss = { eventSink(RoomListEvent.DismissBanner) }, + ) { onDismissClick -> + SetUpRecoveryKeyBanner( + onContinueClick = onSetUpRecoveryClick, + onDismissClick = onDismissClick, + ) + } } SecurityBannerState.RecoveryKeyConfirmation -> { - ConfirmRecoveryKeyBanner( - onContinueClick = onConfirmRecoveryKeyClick, - onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, - ) + DismissibleBanner( + onDismiss = { eventSink(RoomListEvent.DismissBanner) }, + ) { onDismissClick -> + ConfirmRecoveryKeyBanner( + onContinueClick = onConfirmRecoveryKeyClick, + onDismissClick = onDismissClick, + ) + } } SecurityBannerState.None -> Unit } @@ -228,60 +238,26 @@ private fun RoomsViewList( modifier: Modifier = Modifier, ) { OnVisibleRangeChangeEffect(lazyListState) { visibleRange -> - eventSink(RoomListEvent.UpdateVisibleRange(visibleRange)) + // The first item of the list is the banner slot, remove it to get the range of the rooms + val roomsRange = (visibleRange.first - 1).coerceAtLeast(0)..(visibleRange.last - 1) + eventSink(RoomListEvent.UpdateVisibleRange(roomsRange)) } LazyColumn( state = lazyListState, modifier = modifier, contentPadding = contentPadding, ) { - when (state.securityBannerState) { - SecurityBannerState.SetUpRecovery -> { - item { - SetUpRecoveryKeyBanner( - onContinueClick = onSetUpRecoveryClick, - onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, - ) - } - } - SecurityBannerState.RecoveryKeyConfirmation -> { - item { - ConfirmRecoveryKeyBanner( - onContinueClick = onConfirmRecoveryKeyClick, - onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, - ) - } - } - // Banner precedence (top-to-bottom): full-screen-intent > battery-optimization > - // new-notification-sound > swipe-avatar > sound-unavailable. At most one renders at a time. - SecurityBannerState.None -> when { - state.fullScreenIntentPermissionsState.shouldDisplayBanner -> { - item { - FullScreenIntentPermissionBanner(state = state.fullScreenIntentPermissionsState) - } - } - state.batteryOptimizationState.shouldDisplayBanner -> { - item { - BatteryOptimizationBanner(state = state.batteryOptimizationState) - } - } - state.showNewNotificationSoundBanner -> { - item { - NewNotificationSoundBanner( - onDismissClick = { eventSink(RoomListEvent.DismissNewNotificationSoundBanner) }, - ) - } - } - state.showSwipeAvatarBanner -> { - item { - SwipeAvatarBanner( - onDismissClick = { eventSink(RoomListEvent.DismissSwipeAvatarBanner) }, - ) - } - } - } + // The banner slot is always the first item of the list, even when there is no banner to display, + // so that the rooms keep the same index when a banner is added or removed. Since the rooms do not + // have a key, this is required to keep the scroll position. + item { + RoomsViewBanner( + state = state, + eventSink = eventSink, + onSetUpRecoveryClick = onSetUpRecoveryClick, + onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, + ) } - // Note: do not use a key for the LazyColumn, or the scroll will not behave as expected if a room // is moved to the top of the list. itemsIndexed( @@ -305,6 +281,79 @@ private fun RoomsViewList( } } +@Composable +private fun RoomsViewBanner( + state: RoomListContentState.Rooms, + eventSink: (RoomListEvent) -> Unit, + onSetUpRecoveryClick: () -> Unit, + onConfirmRecoveryKeyClick: () -> Unit, +) { + when (state.securityBannerState) { + SecurityBannerState.SetUpRecovery -> { + DismissibleBanner( + onDismiss = { eventSink(RoomListEvent.DismissBanner) }, + ) { onDismissClick -> + SetUpRecoveryKeyBanner( + onContinueClick = onSetUpRecoveryClick, + onDismissClick = onDismissClick, + ) + } + } + SecurityBannerState.RecoveryKeyConfirmation -> { + DismissibleBanner( + onDismiss = { eventSink(RoomListEvent.DismissBanner) }, + ) { onDismissClick -> + ConfirmRecoveryKeyBanner( + onContinueClick = onConfirmRecoveryKeyClick, + onDismissClick = onDismissClick, + ) + } + } + // Banner precedence (top-to-bottom): full-screen-intent > battery-optimization > + // new-notification-sound > swipe-avatar > sound-unavailable. At most one renders at a time. + SecurityBannerState.None -> when { + state.fullScreenIntentPermissionsState.shouldDisplayBanner -> { + DismissibleBanner( + onDismiss = { state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvent.Dismiss) }, + ) { onDismissClick -> + FullScreenIntentPermissionBanner( + onActionClick = { state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvent.OpenSettings) }, + onDismissClick = onDismissClick, + ) + } + } + state.batteryOptimizationState.shouldDisplayBanner -> { + DismissibleBanner( + onDismiss = { state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.Dismiss) }, + ) { onDismissClick -> + BatteryOptimizationBanner( + onActionClick = { state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.RequestDisableOptimizations) }, + onDismissClick = onDismissClick, + ) + } + } + state.showNewNotificationSoundBanner -> { + DismissibleBanner( + onDismiss = { eventSink(RoomListEvent.DismissNewNotificationSoundBanner) }, + ) { onDismissClick -> + NewNotificationSoundBanner( + onDismissClick = onDismissClick, + ) + } + } + state.showSwipeAvatarBanner -> { + DismissibleBanner( + onDismiss = { eventSink(RoomListEvent.DismissSwipeAvatarBanner) }, + ) { onDismissClick -> + SwipeAvatarBanner( + onDismissClick = onDismissClick, + ) + } + } + } + } +} + @Composable private fun EmptyViewForFilterStates( selectedFilters: ImmutableList, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt index 2c158d3ef9..e93aef71f5 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt @@ -51,9 +51,10 @@ class RoomListViewTest : RobolectricTest() { ) ) + // The banner is not included in the range, only the rooms are eventsRecorder.assertList( listOf( - RoomListEvent.UpdateVisibleRange(0..5), + RoomListEvent.UpdateVisibleRange(0..4), ) ) } @@ -64,15 +65,19 @@ class RoomListViewTest : RobolectricTest() { setRoomListView( state = aRoomListState( contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation), - eventSink = eventsRecorder, + // Ignore the visible range updates, which are also emitted when the banner is shrinking + eventSink = { event -> + if (event !is RoomListEvent.UpdateVisibleRange) { + eventsRecorder(event) + } + }, ) ) - // Remove automatic initial events - eventsRecorder.clear() - val close = activity!!.getString(CommonStrings.action_close) onNodeWithContentDescription(close).performClick() + // The event is emitted at the end of the animation + waitForIdle() eventsRecorder.assertSingle(RoomListEvent.DismissBanner) } @@ -82,15 +87,19 @@ class RoomListViewTest : RobolectricTest() { setRoomListView( state = aRoomListState( contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery), - eventSink = eventsRecorder, + // Ignore the visible range updates, which are also emitted when the banner is shrinking + eventSink = { event -> + if (event !is RoomListEvent.UpdateVisibleRange) { + eventsRecorder(event) + } + }, ) ) - // Remove automatic initial events - eventsRecorder.clear() - val close = activity!!.getString(CommonStrings.action_close) onNodeWithContentDescription(close).performClick() + // The event is emitted at the end of the animation + waitForIdle() eventsRecorder.assertSingle(RoomListEvent.DismissBanner) } @@ -100,7 +109,7 @@ class RoomListViewTest : RobolectricTest() { setRoomListView( state = aRoomListState( contentState = aRoomsContentState(showSwipeAvatarBanner = true), - // Ignore the visible range updates + // Ignore the visible range updates, which are also emitted when the banner is shrinking eventSink = { event -> if (event !is RoomListEvent.UpdateVisibleRange) { eventsRecorder(event) @@ -112,6 +121,8 @@ class RoomListViewTest : RobolectricTest() { eventsRecorder.assertEmpty() val close = activity!!.getString(CommonStrings.action_close) onNodeWithContentDescription(close).performClick() + // The event is emitted at the end of the animation + waitForIdle() eventsRecorder.assertSingle(RoomListEvent.DismissSwipeAvatarBanner) } From 0dce4b9e493eae28c1bdc8c4d63d63c4fe57b7e0 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Tue, 13 Oct 2026 10:11:22 +0000 Subject: [PATCH 4/9] Update screenshots --- ...tures.home.impl.components_RoomListContentView_Day_6_en.png | 3 +++ ...res.home.impl.components_RoomListContentView_Night_6_en.png | 3 +++ ...eatures.home.impl.components_SwipeAvatarBanner_Day_0_en.png | 3 +++ ...tures.home.impl.components_SwipeAvatarBanner_Night_0_en.png | 3 +++ 4 files changed, 12 insertions(+) create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Day_6_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Night_6_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.components_SwipeAvatarBanner_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.components_SwipeAvatarBanner_Night_0_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Day_6_en.png new file mode 100644 index 0000000000..2e8b5c11be --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Day_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bae5109ecc9377e0f1d299a69bb5cc66d7a9432de3590d4a5e308abde7db61a9 +size 64511 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Night_6_en.png new file mode 100644 index 0000000000..e5d57b8a2a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Night_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:04b58c0184ac8b9ff9b59c53125db66b890efbf09443f53e1f9091e2894519e4 +size 63677 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_SwipeAvatarBanner_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_SwipeAvatarBanner_Day_0_en.png new file mode 100644 index 0000000000..3b74f9c1ba --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_SwipeAvatarBanner_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5a9bab9bae4bd59e54e7dc2897367b110458baf184d9abb4ad801b76d6cb1432 +size 28089 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_SwipeAvatarBanner_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_SwipeAvatarBanner_Night_0_en.png new file mode 100644 index 0000000000..ed0bcd0d48 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_SwipeAvatarBanner_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ead5c82cfb8ccac0a666e3c9d41121206932457ffd1047f268390b4f71ef198e +size 27439 From f187409db3340d5afe2603e3f0e6eeff6a5419d0 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 13 Oct 2026 12:55:50 +0200 Subject: [PATCH 5/9] Apply suggestion: 'rangeTo' or the '..' call should be replaced with '..<' --- .../features/home/impl/components/RoomListContentView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 874f4f4784..09dc51d68f 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 @@ -239,7 +239,7 @@ private fun RoomsViewList( ) { OnVisibleRangeChangeEffect(lazyListState) { visibleRange -> // The first item of the list is the banner slot, remove it to get the range of the rooms - val roomsRange = (visibleRange.first - 1).coerceAtLeast(0)..(visibleRange.last - 1) + val roomsRange = (visibleRange.first - 1).coerceAtLeast(0).. Date: Wed, 14 Oct 2026 17:04:32 +0200 Subject: [PATCH 6/9] Animate all the banner changes of the room list in a single slot Replace DismissibleBanner, which used one AnimatedVisibility per banner, by RoomListBannerSlot, which renders the banner to display with a single AnimatedContent. All the changes are now animated: dismissing a banner, replacing a banner by the next one, and displaying a new banner. Previously, when a banner was dismissed, the next banner was displayed without animation. The dismiss event is now sent immediately, AnimatedContent keeps rendering the dismissed banner while it animates out. The banner slot of the LazyColumn now has a min height of 1px. When it was 0px high, the LazyColumn was considering it as not visible and moved the first visible item to the first room, so a new banner was added above the visible area of the list. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../home/impl/components/DismissibleBanner.kt | 78 ------- .../impl/components/RoomListContentView.kt | 217 ++++++++++-------- .../home/impl/roomlist/RoomListState.kt | 33 +++ .../home/impl/roomlist/RoomListViewTest.kt | 6 - 4 files changed, 153 insertions(+), 181 deletions(-) delete mode 100644 features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/DismissibleBanner.kt diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/DismissibleBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/DismissibleBanner.kt deleted file mode 100644 index 1c743ec8dd..0000000000 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/DismissibleBanner.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright (c) 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.home.impl.components - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import kotlinx.coroutines.flow.first - -/** - * Wraps a banner of the room list so that it shrinks and fades out when it is dismissed. - * [onDismiss] is invoked once the animation is over, so that the banner is removed from the list - * when its height is 0, and the items below it are not moved abruptly. - * - * @param onDismiss invoked when the banner has been dismissed and the animation is over. - * @param modifier the modifier to apply to the banner container. - * @param content the banner, which must invoke the provided lambda when the user dismisses it. - */ -@Composable -internal fun DismissibleBanner( - onDismiss: () -> Unit, - modifier: Modifier = Modifier, - content: @Composable (onDismissClick: () -> Unit) -> Unit, -) { - val visibleState = remember { MutableTransitionState(true) } - val latestOnDismiss by rememberUpdatedState(onDismiss) - var isDismissNotified by remember { mutableStateOf(false) } - fun notifyDismiss() { - if (!isDismissNotified) { - isDismissNotified = true - latestOnDismiss() - } - } - LaunchedEffect(visibleState) { - snapshotFlow { visibleState.isIdle && !visibleState.currentState }.first { it } - notifyDismiss() - } - DisposableEffect(visibleState) { - onDispose { - // The item can be disposed during the animation, for instance if the user scrolls the list - if (!visibleState.targetState) { - notifyDismiss() - } - } - } - AnimatedVisibility( - visibleState = visibleState, - modifier = modifier, - enter = EnterTransition.None, - exit = shrinkVertically( - animationSpec = tween(), - shrinkTowards = Alignment.CenterVertically, - ) + fadeOut( - animationSpec = tween(), - ), - ) { - content { visibleState.targetState = false } - } -} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt index 09dc51d68f..02288eb8b7 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 @@ -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 @@ -160,30 +170,20 @@ private fun EmptyView( }, modifier = Modifier.align(Alignment.Center), ) - Box { - when (state.securityBannerState) { - SecurityBannerState.SetUpRecovery -> { - DismissibleBanner( - onDismiss = { eventSink(RoomListEvent.DismissBanner) }, - ) { onDismissClick -> - SetUpRecoveryKeyBanner( - onContinueClick = onSetUpRecoveryClick, - onDismissClick = onDismissClick, - ) + RoomListBannerContainer( + banner = state.securityBannerState.toRoomListBanner(), + ) { banner -> + RoomListBannerView( + banner = banner, + onActionClick = { + when (banner) { + RoomListBanner.SetUpRecovery -> onSetUpRecoveryClick() + RoomListBanner.RecoveryKeyConfirmation -> onConfirmRecoveryKeyClick() + else -> Unit } - } - SecurityBannerState.RecoveryKeyConfirmation -> { - DismissibleBanner( - onDismiss = { eventSink(RoomListEvent.DismissBanner) }, - ) { onDismissClick -> - ConfirmRecoveryKeyBanner( - onContinueClick = onConfirmRecoveryKeyClick, - onDismissClick = onDismissClick, - ) - } - } - SecurityBannerState.None -> Unit - } + }, + onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, + ) } } } @@ -237,6 +237,10 @@ 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 -> // 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).. + 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. @@ -281,79 +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 RoomsViewBanner( - state: RoomListContentState.Rooms, - eventSink: (RoomListEvent) -> Unit, - onSetUpRecoveryClick: () -> Unit, - onConfirmRecoveryKeyClick: () -> Unit, +private fun RoomListBannerContainer( + banner: RoomListBanner?, + modifier: Modifier = Modifier, + content: @Composable (RoomListBanner) -> Unit, ) { - when (state.securityBannerState) { - SecurityBannerState.SetUpRecovery -> { - DismissibleBanner( - onDismiss = { eventSink(RoomListEvent.DismissBanner) }, - ) { onDismissClick -> - SetUpRecoveryKeyBanner( - onContinueClick = onSetUpRecoveryClick, - onDismissClick = onDismissClick, - ) - } - } - SecurityBannerState.RecoveryKeyConfirmation -> { - DismissibleBanner( - onDismiss = { eventSink(RoomListEvent.DismissBanner) }, - ) { onDismissClick -> - ConfirmRecoveryKeyBanner( - onContinueClick = onConfirmRecoveryKeyClick, - onDismissClick = onDismissClick, - ) - } - } - // Banner precedence (top-to-bottom): full-screen-intent > battery-optimization > - // new-notification-sound > swipe-avatar > sound-unavailable. At most one renders at a time. - SecurityBannerState.None -> when { - state.fullScreenIntentPermissionsState.shouldDisplayBanner -> { - DismissibleBanner( - onDismiss = { state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvent.Dismiss) }, - ) { onDismissClick -> - FullScreenIntentPermissionBanner( - onActionClick = { state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvent.OpenSettings) }, - onDismissClick = onDismissClick, - ) - } - } - state.batteryOptimizationState.shouldDisplayBanner -> { - DismissibleBanner( - onDismiss = { state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.Dismiss) }, - ) { onDismissClick -> - BatteryOptimizationBanner( - onActionClick = { state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.RequestDisableOptimizations) }, - onDismissClick = onDismissClick, - ) - } - } - state.showNewNotificationSoundBanner -> { - DismissibleBanner( - onDismiss = { eventSink(RoomListEvent.DismissNewNotificationSoundBanner) }, - ) { onDismissClick -> - NewNotificationSoundBanner( - onDismissClick = onDismissClick, - ) - } - } - state.showSwipeAvatarBanner -> { - DismissibleBanner( - onDismiss = { eventSink(RoomListEvent.DismissSwipeAvatarBanner) }, - ) { onDismissClick -> - SwipeAvatarBanner( - onDismissClick = onDismissClick, - ) - } - } + 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, 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 6d3d85ab56..f77695399b 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 @@ -61,6 +61,18 @@ enum class SecurityBannerState { RecoveryKeyConfirmation, } +/** + * 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, +} + @Immutable sealed interface RoomListContentState { data class Skeleton(val count: Int) : RoomListContentState @@ -80,3 +92,24 @@ sealed interface RoomListContentState { val showAllActivity: Boolean, ) : RoomListContentState } + +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 + } +} + 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 e93aef71f5..eb41331202 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 @@ -76,8 +76,6 @@ class RoomListViewTest : RobolectricTest() { val close = activity!!.getString(CommonStrings.action_close) onNodeWithContentDescription(close).performClick() - // The event is emitted at the end of the animation - waitForIdle() eventsRecorder.assertSingle(RoomListEvent.DismissBanner) } @@ -98,8 +96,6 @@ class RoomListViewTest : RobolectricTest() { val close = activity!!.getString(CommonStrings.action_close) onNodeWithContentDescription(close).performClick() - // The event is emitted at the end of the animation - waitForIdle() eventsRecorder.assertSingle(RoomListEvent.DismissBanner) } @@ -121,8 +117,6 @@ class RoomListViewTest : RobolectricTest() { eventsRecorder.assertEmpty() val close = activity!!.getString(CommonStrings.action_close) onNodeWithContentDescription(close).performClick() - // The event is emitted at the end of the animation - waitForIdle() eventsRecorder.assertSingle(RoomListEvent.DismissSwipeAvatarBanner) } From e186e62a0ea2d652d3dfaf313f9948ecdf0f0479 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 14 Oct 2026 17:41:07 +0200 Subject: [PATCH 7/9] Move RoomListBanner to its own file The test source set also has a RoomListState.kt file in the same package, so its RoomListStateKt class was shadowing the one of the main source set, and the toRoomListBanner() functions were not found when running the tests (NoSuchMethodError). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../home/impl/roomlist/RoomListBanner.kt | 40 +++++++++++++++++++ .../home/impl/roomlist/RoomListState.kt | 33 --------------- 2 files changed, 40 insertions(+), 33 deletions(-) create mode 100644 features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListBanner.kt diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListBanner.kt new file mode 100644 index 0000000000..c273cc9ce4 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListBanner.kt @@ -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 + } +} 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 f77695399b..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 @@ -61,18 +61,6 @@ enum class SecurityBannerState { RecoveryKeyConfirmation, } -/** - * 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, -} - @Immutable sealed interface RoomListContentState { data class Skeleton(val count: Int) : RoomListContentState @@ -92,24 +80,3 @@ sealed interface RoomListContentState { val showAllActivity: Boolean, ) : RoomListContentState } - -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 - } -} - From c3f178985d0fe305ed8feb85eb33336e2dfd01fd Mon Sep 17 00:00:00 2001 From: ElementBot Date: Wed, 14 Oct 2026 16:00:19 +0000 Subject: [PATCH 8/9] Update screenshots --- ...ures.home.impl.components_RoomListContentView_Day_0_en.png | 4 ++-- ...es.home.impl.components_RoomListContentView_Night_0_en.png | 4 ++-- .../snapshots/images/features.home.impl_HomeViewA11y_en.png | 4 ++-- .../snapshots/images/features.home.impl_HomeView_Day_0_en.png | 4 ++-- .../images/features.home.impl_HomeView_Day_17_en.png | 4 ++-- .../snapshots/images/features.home.impl_HomeView_Day_1_en.png | 4 ++-- .../snapshots/images/features.home.impl_HomeView_Day_2_en.png | 4 ++-- .../snapshots/images/features.home.impl_HomeView_Day_3_en.png | 4 ++-- .../snapshots/images/features.home.impl_HomeView_Day_5_en.png | 4 ++-- .../snapshots/images/features.home.impl_HomeView_Day_6_en.png | 4 ++-- .../snapshots/images/features.home.impl_HomeView_Day_7_en.png | 4 ++-- .../snapshots/images/features.home.impl_HomeView_Day_8_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_0_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_17_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_1_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_2_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_3_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_5_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_6_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_7_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_8_en.png | 4 ++-- 21 files changed, 42 insertions(+), 42 deletions(-) diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Day_0_en.png index 8106e587cf..5081cf144a 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:96a167627e1af6e5db7ecc39efb0d52a2ad3e23f84c2cd1222f088102fd22303 -size 41053 +oid sha256:7965b2152ccf703ca1ce05103835360e5a6c105708ddda2af2f3ebe08311d627 +size 41025 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Night_0_en.png index a6ccbb245e..5975364c78 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_RoomListContentView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:875e9afdf2d3b6b67ad2376a788b2ec92fa3099a4b6882ee6834da1ec4dad7de -size 40836 +oid sha256:3f2a815d81ef6d332ca4a76ea82cc20cced9069f82defece60ff7d6ba6c3bdd9 +size 40914 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeViewA11y_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeViewA11y_en.png index 8534820c63..2adbae0e28 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeViewA11y_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeViewA11y_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:70793555aa11eede96e92e3d9a9186c75c4056a5f193bb5a3962227208777866 -size 143038 +oid sha256:932bd7fc62a301bd910dec13461b10bbc3a5d8df3dd7047f20ebc2d357b88f31 +size 141308 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_0_en.png index 665ffd51f2..0ef6f18ac9 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5b7f126c54b4e32c57c62ee5d759683ac538d95649f9bd90182e1b2990bb60f7 -size 68021 +oid sha256:1e86235f836e89a9748d68dde6287de5c840a70f39dd76c3f43f1e360cb3abc3 +size 67994 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_17_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_17_en.png index 342d1ab6ff..a13655bf74 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_17_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_17_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b5c44832e3d4cbb637d48abc19174a66bdff343de3e60aa94c3d6bfaac44e78d -size 62363 +oid sha256:004f6237e1f07aa2a62930b93e9f45d8ebd4abc7b359c974bb487ad586f60357 +size 62250 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_1_en.png index 665ffd51f2..0ef6f18ac9 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5b7f126c54b4e32c57c62ee5d759683ac538d95649f9bd90182e1b2990bb60f7 -size 68021 +oid sha256:1e86235f836e89a9748d68dde6287de5c840a70f39dd76c3f43f1e360cb3abc3 +size 67994 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_2_en.png index 665ffd51f2..0ef6f18ac9 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5b7f126c54b4e32c57c62ee5d759683ac538d95649f9bd90182e1b2990bb60f7 -size 68021 +oid sha256:1e86235f836e89a9748d68dde6287de5c840a70f39dd76c3f43f1e360cb3abc3 +size 67994 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_3_en.png index cfd1827f50..ab727d2957 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6618a3f641c4919fd009f8bab432cd0d03ee933accf5b33815202eed721c78bf -size 65320 +oid sha256:229bdf660691f48123b4d7b0de033032f5450fb24dae2df657672231f7368c7c +size 65167 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_5_en.png index 665ffd51f2..0ef6f18ac9 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5b7f126c54b4e32c57c62ee5d759683ac538d95649f9bd90182e1b2990bb60f7 -size 68021 +oid sha256:1e86235f836e89a9748d68dde6287de5c840a70f39dd76c3f43f1e360cb3abc3 +size 67994 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_6_en.png index 516089368e..5b193f20cd 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fd658e684c89fc1b201a4a4090037d83c8882774b2a2593b12b30bd011971ecf -size 55324 +oid sha256:c4f07548ec04398f5446d01156decfe01110f41161fb7355207768ab6341c593 +size 55183 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_7_en.png index f4bb27c8b4..7674a58449 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4c5798c49f07cd4a564c6b48c18dc0a67b5437e403da63e154c6bdbcee9fb060 -size 55128 +oid sha256:83e0e84e890b40d4db862ff65689e3234abb205b7aa759bd0c653af47cf0f53e +size 54984 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_8_en.png index b43498911a..55ff492464 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4c8042cfa5d850e0322da8a6cb1fb019da4d3675c469d29aec84fe9d6a8aa147 -size 53368 +oid sha256:c55cff055873cf6301962c164865f728032e08a6ab4c676be84ddddddaadc99e +size 53226 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_0_en.png index 4b4b7a11cd..7904b5cee7 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9ca44c782087bba8cb2481318a3bbe902c5c6aafd6dc05b441fb486c6e72f892 -size 62673 +oid sha256:cb4dd312bb803fc72a94aed07063bf0c4f0ca7afb83495062c8c8ab0792d7ad9 +size 62720 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_17_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_17_en.png index a2a191b30f..453c5f37b7 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_17_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_17_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:513e23929e81026a24cb2d964fd8a65ba4998ac715021d480d7e7211d6a48d0a -size 59148 +oid sha256:54a0b9e36eb0091d7a29d6b88e0e40424259ab04ff3544017ce249367109fd0b +size 58996 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_1_en.png index 4b4b7a11cd..7904b5cee7 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9ca44c782087bba8cb2481318a3bbe902c5c6aafd6dc05b441fb486c6e72f892 -size 62673 +oid sha256:cb4dd312bb803fc72a94aed07063bf0c4f0ca7afb83495062c8c8ab0792d7ad9 +size 62720 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_2_en.png index 4b4b7a11cd..7904b5cee7 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9ca44c782087bba8cb2481318a3bbe902c5c6aafd6dc05b441fb486c6e72f892 -size 62673 +oid sha256:cb4dd312bb803fc72a94aed07063bf0c4f0ca7afb83495062c8c8ab0792d7ad9 +size 62720 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_3_en.png index a1d6128daa..87b9d4b528 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:89e25edf690c8c416699e82781a4dd01f4ab370e8ca868b7bd4ce1a0e5a1124e -size 60532 +oid sha256:0b6c5ebd0eea3715dc7f053ec196223f64226231f41f7a03251784828a0dd876 +size 60371 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_5_en.png index 4b4b7a11cd..7904b5cee7 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9ca44c782087bba8cb2481318a3bbe902c5c6aafd6dc05b441fb486c6e72f892 -size 62673 +oid sha256:cb4dd312bb803fc72a94aed07063bf0c4f0ca7afb83495062c8c8ab0792d7ad9 +size 62720 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_6_en.png index 14dc407833..f065d6e748 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:784de072b6803569aaba3fddfeb1301ace32f51d2a3b78d823ccaaa77fd5d52e -size 52222 +oid sha256:5b90e992b69d06287ce60300b796da1ac17dddea00922f2e42dfdf63631e8a3e +size 52027 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_7_en.png index d4f6602825..fc2be0901e 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fdec8293d351e9b879bafdff048a9ae3bdeaf34d1e47125fe94386415ef80ac4 -size 51941 +oid sha256:bf551434aa4ea85f250fab838aabb77a41b834f8bfdba5cdcd1263e16e67fb39 +size 51759 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_8_en.png index 2dcffff9f8..c7e8c0a35f 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f0599ae5bfbd84c1087b06b1e50352fd21b0d784e62536868f012f8a3ab8da95 -size 50257 +oid sha256:95001ba7ba09516aa2da086e07d3dbf2931698f36c9c8425155204e0f8d0bf2d +size 50058 From 9efe0db5709b1da1d3ceafca00715584104c354f Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 14 Oct 2026 17:56:54 +0200 Subject: [PATCH 9/9] Dismiss the swipe avatar announcement in the app coroutine scope HomeView sends HomeEvent.DismissSwipeAvatarAnnouncement right before switching account. Switching account replaces the LoggedInFlow root node, which destroys HomePresenter and cancels its composition coroutine scope, so the dismissal could be cancelled before the announcement status was stored. Use the app coroutine scope instead, which survives the account switch. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../android/features/home/impl/HomePresenter.kt | 11 ++++++++--- .../android/features/home/impl/HomePresenterTest.kt | 7 ++++++- 2 files changed, 14 insertions(+), 4 deletions(-) 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 3f4901d5f2..c250569976 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 @@ -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 { 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,10 +130,11 @@ class HomePresenter( is HomeEvent.SelectHomeNavigationBarItem -> { currentHomeNavigationBarItemOrdinal = event.item.ordinal } - HomeEvent.DismissSwipeAvatarAnnouncement -> 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 -> coroutineState.launch { + HomeEvent.DismissMultiAccountAnnouncement -> coroutineScope.launch { announcementService.onAnnouncementDismissed(Announcement.MultiAccount) } } 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 6af53d2ace..8bd779ef05 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 @@ -39,8 +39,10 @@ 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 @@ -292,6 +294,7 @@ class HomePresenterTest { announcementService = FakeAnnouncementService( onAnnouncementDismissedResult = onAnnouncementDismissedResult, ), + appCoroutineScope = this, ) presenter.test { val state = expectMostRecentItem() @@ -313,7 +316,7 @@ class HomePresenterTest { } } -internal fun createHomePresenter( +internal fun TestScope.createHomePresenter( client: MatrixClient = FakeMatrixClient(), syncService: SyncService = FakeSyncService(), snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), @@ -323,6 +326,7 @@ internal fun createHomePresenter( sessionStore: SessionStore = InMemorySessionStore(), announcementService: AnnouncementService = FakeAnnouncementService(), featureFlagService: FeatureFlagService = FakeFeatureFlagService(), + appCoroutineScope: CoroutineScope = backgroundScope, ) = HomePresenter( client = client, syncService = syncService, @@ -334,4 +338,5 @@ internal fun createHomePresenter( sessionStore = sessionStore, announcementService = announcementService, featureFlagService = featureFlagService, + appCoroutineScope = appCoroutineScope, )