mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-03 06:01:40 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
+3
@@ -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 {
|
||||
|
||||
+7
-3
@@ -92,6 +92,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.filter
|
||||
|
||||
@Composable
|
||||
fun HomeTopBar(
|
||||
@@ -307,9 +308,12 @@ private fun NavigationIcon(
|
||||
// Listen to page changes and switch account if needed
|
||||
val latestOnAccountSwitch by rememberUpdatedState(onAccountSwitch)
|
||||
LaunchedEffect(pagerState) {
|
||||
snapshotFlow { pagerState.settledPage }.collect { page ->
|
||||
latestOnAccountSwitch(SessionId(currentUserAndNeighbors[page].userId.value))
|
||||
}
|
||||
snapshotFlow { pagerState.settledPage }
|
||||
// The current account is on page 1, so there is nothing to switch to
|
||||
.filter { page -> page != 1 }
|
||||
.collect { page ->
|
||||
latestOnAccountSwitch(SessionId(currentUserAndNeighbors[page].userId.value))
|
||||
}
|
||||
}
|
||||
VerticalPager(
|
||||
state = pagerState,
|
||||
|
||||
+8
-1
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.home.impl.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.features.home.impl.R
|
||||
import io.element.android.libraries.designsystem.components.Announcement
|
||||
import io.element.android.libraries.designsystem.components.AnnouncementType
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
internal fun SwipeAvatarBanner(
|
||||
onDismissClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Announcement(
|
||||
modifier = modifier.roomListBannerPadding(),
|
||||
title = stringResource(R.string.banner_swipe_avatar_title),
|
||||
description = stringResource(R.string.banner_swipe_avatar_content),
|
||||
type = AnnouncementType.Actionable(
|
||||
actionText = stringResource(CommonStrings.action_ok),
|
||||
onActionClick = onDismissClick,
|
||||
onDismissClick = onDismissClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun SwipeAvatarBannerPreview() = ElementPreview {
|
||||
SwipeAvatarBanner(
|
||||
onDismissClick = {},
|
||||
)
|
||||
}
|
||||
+5
@@ -30,12 +30,16 @@ open class RoomListContentStatePreviewParam : PreviewParameterProvider<RoomListC
|
||||
aRoomsContentState(
|
||||
showNewNotificationSoundBanner = true,
|
||||
),
|
||||
aRoomsContentState(
|
||||
showSwipeAvatarBanner = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun aRoomsContentState(
|
||||
securityBannerState: SecurityBannerState = SecurityBannerState.None,
|
||||
showNewNotificationSoundBanner: Boolean = false,
|
||||
showSwipeAvatarBanner: Boolean = false,
|
||||
showUnreadCount: Boolean = false,
|
||||
summaries: ImmutableList<RoomListRoomSummary> = aRoomListRoomSummaryList(),
|
||||
fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(),
|
||||
@@ -45,6 +49,7 @@ internal fun aRoomsContentState(
|
||||
) = RoomListContentState.Rooms(
|
||||
securityBannerState = securityBannerState,
|
||||
showNewNotificationSoundBanner = showNewNotificationSoundBanner,
|
||||
showSwipeAvatarBanner = showSwipeAvatarBanner,
|
||||
showUnreadCount = showUnreadCount,
|
||||
fullScreenIntentPermissionsState = fullScreenIntentPermissionsState,
|
||||
batteryOptimizationState = batteryOptimizationState,
|
||||
|
||||
+1
@@ -16,6 +16,7 @@ sealed interface RoomListEvent {
|
||||
data object DismissRequestVerificationPrompt : RoomListEvent
|
||||
data object DismissBanner : RoomListEvent
|
||||
data object DismissNewNotificationSoundBanner : RoomListEvent
|
||||
data object DismissSwipeAvatarBanner : RoomListEvent
|
||||
data object ToggleSearchResults : RoomListEvent
|
||||
data class ShowContextMenu(val roomSummary: RoomListRoomSummary) : RoomListEvent
|
||||
|
||||
|
||||
+18
@@ -56,6 +56,7 @@ import io.element.android.libraries.matrix.api.roomlist.RoomList
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
|
||||
import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar
|
||||
import io.element.android.libraries.push.api.battery.BatteryOptimizationState
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher
|
||||
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
|
||||
@@ -64,6 +65,7 @@ import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -89,6 +91,7 @@ class RoomListPresenter(
|
||||
private val spaceFiltersPresenter: Presenter<SpaceFiltersState>,
|
||||
private val globalSearchPresenter: Presenter<GlobalSearchState>,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val sessionStore: SessionStore,
|
||||
) : Presenter<RoomListState> {
|
||||
private val encryptionService = client.encryptionService
|
||||
|
||||
@@ -112,6 +115,15 @@ class RoomListPresenter(
|
||||
announcements.contains(Announcement.NewNotificationSound)
|
||||
}
|
||||
}.collectAsState(false)
|
||||
val showSwipeAvatarBanner by remember {
|
||||
combine(
|
||||
announcementService.announcementsToShowFlow(),
|
||||
sessionStore.sessionsFlow(),
|
||||
) { announcements, sessions ->
|
||||
// The swipe gesture on the avatar is only available when there are several accounts
|
||||
sessions.size >= 2 && announcements.contains(Announcement.SwipeAvatarToSwitchAccount)
|
||||
}
|
||||
}.collectAsState(false)
|
||||
|
||||
// Avatar indicator
|
||||
val hideInvitesAvatar by client.rememberHideInvitesAvatar()
|
||||
@@ -133,6 +145,9 @@ class RoomListPresenter(
|
||||
RoomListEvent.DismissNewNotificationSoundBanner -> coroutineScope.launch {
|
||||
announcementService.onAnnouncementDismissed(Announcement.NewNotificationSound)
|
||||
}
|
||||
RoomListEvent.DismissSwipeAvatarBanner -> coroutineScope.launch {
|
||||
announcementService.onAnnouncementDismissed(Announcement.SwipeAvatarToSwitchAccount)
|
||||
}
|
||||
RoomListEvent.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvent.ToggleSearchVisibility)
|
||||
is RoomListEvent.ShowContextMenu -> {
|
||||
coroutineScope.showContextMenu(event, contextMenu)
|
||||
@@ -176,6 +191,7 @@ class RoomListPresenter(
|
||||
val contentState = roomListContentState(
|
||||
securityBannerDismissed,
|
||||
showNewNotificationSoundBanner,
|
||||
showSwipeAvatarBanner,
|
||||
showUnreadCount,
|
||||
showAllActivity,
|
||||
)
|
||||
@@ -235,6 +251,7 @@ class RoomListPresenter(
|
||||
private fun roomListContentState(
|
||||
securityBannerDismissed: Boolean,
|
||||
showNewNotificationSoundBanner: Boolean,
|
||||
showSwipeAvatarBanner: Boolean,
|
||||
showUnreadCount: Boolean,
|
||||
showAllActivity: Boolean,
|
||||
): RoomListContentState {
|
||||
@@ -265,6 +282,7 @@ class RoomListPresenter(
|
||||
RoomListContentState.Rooms(
|
||||
securityBannerState = securityBannerState,
|
||||
showNewNotificationSoundBanner = showNewNotificationSoundBanner,
|
||||
showSwipeAvatarBanner = showSwipeAvatarBanner,
|
||||
showUnreadCount = showUnreadCount,
|
||||
fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(),
|
||||
batteryOptimizationState = batteryOptimizationPresenter.present(),
|
||||
|
||||
+1
@@ -73,6 +73,7 @@ sealed interface RoomListContentState {
|
||||
val fullScreenIntentPermissionsState: FullScreenIntentPermissionsState,
|
||||
val batteryOptimizationState: BatteryOptimizationState,
|
||||
val showNewNotificationSoundBanner: Boolean,
|
||||
val showSwipeAvatarBanner: Boolean,
|
||||
val showUnreadCount: Boolean,
|
||||
val summaries: ImmutableList<RoomListRoomSummary>,
|
||||
val seenRoomInvites: ImmutableSet<RoomId>,
|
||||
|
||||
+34
@@ -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<Announcement, Unit> { }
|
||||
val setLatestSessionResult = lambdaRecorder<String, Unit> { }
|
||||
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(
|
||||
|
||||
+48
@@ -56,6 +56,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID_3
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
||||
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
|
||||
@@ -72,6 +73,9 @@ import io.element.android.libraries.preferences.test.InMemorySessionPreferencesS
|
||||
import io.element.android.libraries.push.api.battery.aBatteryOptimizationState
|
||||
import io.element.android.libraries.push.api.notifications.NotificationCleaner
|
||||
import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||
import io.element.android.libraries.sessionstorage.test.aSessionData
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher
|
||||
@@ -645,6 +649,48 @@ class RoomListPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - swipe avatar banner`() = runTest {
|
||||
val roomList = FakeDynamicRoomList(
|
||||
summaries = MutableStateFlow(listOf(aRoomSummary())),
|
||||
loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1))
|
||||
)
|
||||
val roomListService = FakeRoomListService(
|
||||
createRoomListLambda = { roomList },
|
||||
subscribeToVisibleRoomsLambda = { },
|
||||
)
|
||||
val matrixClient = FakeMatrixClient(
|
||||
roomListService = roomListService,
|
||||
)
|
||||
val onAnnouncementDismissedResult = lambdaRecorder<Announcement, Unit> { }
|
||||
val announcementService = FakeAnnouncementService(
|
||||
initialAnnouncementsToShowFlowValue = listOf(Announcement.SwipeAvatarToSwitchAccount),
|
||||
onAnnouncementDismissedResult = onAnnouncementDismissedResult,
|
||||
)
|
||||
val sessionStore = InMemorySessionStore(
|
||||
initialList = listOf(aSessionData(sessionId = A_SESSION_ID.value)),
|
||||
)
|
||||
val presenter = createRoomListPresenter(
|
||||
client = matrixClient,
|
||||
announcementService = announcementService,
|
||||
sessionStore = sessionStore,
|
||||
)
|
||||
presenter.test {
|
||||
skipItems(2) // Skip initial state and intermediate event for loading the value of the feature flags
|
||||
val state = awaitItem()
|
||||
// Only one account, the banner is not displayed
|
||||
assertThat(state.contentAsRooms().showSwipeAvatarBanner).isFalse()
|
||||
sessionStore.addSession(aSessionData(sessionId = A_SESSION_ID_2.value))
|
||||
assertThat(awaitItem().contentAsRooms().showSwipeAvatarBanner).isTrue()
|
||||
state.eventSink(RoomListEvent.DismissSwipeAvatarBanner)
|
||||
onAnnouncementDismissedResult.assertions().isCalledOnce()
|
||||
.with(value(Announcement.SwipeAvatarToSwitchAccount))
|
||||
// Simulate service updating the value
|
||||
announcementService.emitAnnouncementsToShow(emptyList())
|
||||
assertThat(awaitItem().contentAsRooms().showSwipeAvatarBanner).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - showAllActivity and showUnreadCount reflect the feature flag values`() = runTest {
|
||||
val roomList = FakeDynamicRoomList(
|
||||
@@ -724,6 +770,7 @@ class RoomListPresenterTest {
|
||||
announcementService: AnnouncementService = FakeAnnouncementService(),
|
||||
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
|
||||
markRoomAsRead: MarkRoomAsRead? = null,
|
||||
sessionStore: SessionStore = InMemorySessionStore(),
|
||||
) = RoomListPresenter(
|
||||
client = client,
|
||||
leaveRoomPresenter = { leaveRoomState },
|
||||
@@ -756,5 +803,6 @@ class RoomListPresenterTest {
|
||||
announcementService = announcementService,
|
||||
coldStartWatcher = FakeAnalyticsColdStartWatcher(),
|
||||
featureFlagService = featureFlagService,
|
||||
sessionStore = sessionStore,
|
||||
)
|
||||
}
|
||||
|
||||
+21
@@ -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<RoomListEvent>()
|
||||
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<RoomListEvent>()
|
||||
|
||||
Reference in New Issue
Block a user