diff --git a/AGENTS.md b/AGENTS.md index a4a73ca965..dc34ebd571 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ PRs must meet these rules. - Create previews for **all main states** of a Composable. - Use `@PreviewsDayNight` for consistency. -- Use `PreviewParameterProvider` (e.g., `FooStatePreviewParam`) to provide states. +- Use `PreviewParameterProvider` (e.g., `FooStatePreviewParam`) to provide states. Always add new states to existing providers at the end of the sequence. - Wrap previews in `ElementPreview { ... }`. - When writing tests, never try to record the screenshots, the CI will do it. diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 370c0db2f1..dce0911165 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -359,6 +359,10 @@ class LoggedInFlowNode( override fun navigateToBugReport() { callback.navigateToBugReport() } + + override fun navigateToAddAccount() { + callback.navigateToAddAccount() + } } homeEntryPoint.createNode( parentNode = this, diff --git a/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt index 080f3c1d77..8b8b323aab 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt @@ -13,6 +13,7 @@ package io.element.android.appnav import android.os.Parcelable import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope import coil3.SingletonImageLoader import coil3.annotation.DelicateCoilApi import com.bumble.appyx.core.lifecycle.subscribe @@ -24,6 +25,8 @@ import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode +import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.login.api.LoginEntryPoint import io.element.android.features.login.api.LoginParams import io.element.android.libraries.architecture.BackstackView @@ -33,8 +36,11 @@ import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.inputs import io.element.android.libraries.designsystem.utils.ForceOrientationInMobileDevices import io.element.android.libraries.designsystem.utils.ScreenOrientation +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize @ContributesNode(AppScope::class) @@ -45,6 +51,8 @@ class NotLoggedInFlowNode( private val loginEntryPoint: LoginEntryPoint, private val imageLoaderHolder: ImageLoaderHolder, private val analyticsColdStartWatcher: AnalyticsColdStartWatcher, + private val featureFlagService: FeatureFlagService, + private val announcementService: AnnouncementService, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.Root, @@ -68,6 +76,13 @@ class NotLoggedInFlowNode( override fun onBuilt() { super.onBuilt() analyticsColdStartWatcher.whenLoggingIn() + lifecycleScope.launch { + // Users who enter the login flow while the feature is available, either for the first time or to add + // another account, do not need to be informed about the multi-account feature. + if (featureFlagService.isFeatureEnabled(FeatureFlags.MultiAccount)) { + announcementService.onAnnouncementDismissed(Announcement.MultiAccount) + } + } lifecycle.subscribe( onResume = { SingletonImageLoader.setUnsafe(imageLoaderHolder.get()) diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt index cc23aea626..dd0a10c2ea 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt @@ -141,6 +141,8 @@ class RootFlowNodeTest : RobolectricTest() { loginEntryPoint = loginEntryPoint, imageLoaderHolder = FakeImageLoaderHolder(), analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(), + featureFlagService = FakeFeatureFlagService(), + announcementService = FakeAnnouncementService(), ) } ) 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 d743ae4cd6..69def559a1 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 @@ -17,4 +17,6 @@ sealed interface Announcement { } data object NewNotificationSound : Announcement + + data object MultiAccount : 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 adb81db61a..3c36989571 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 @@ -29,10 +29,11 @@ class DefaultAnnouncementService( ) : AnnouncementService { override suspend fun showAnnouncement(announcement: Announcement) { when (announcement) { - is Announcement.Fullscreen -> showFullscreenAnnouncement(announcement) + is Announcement.Fullscreen -> showAnnouncementIfNeverShown(announcement) Announcement.NewNotificationSound -> { announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show) } + Announcement.MultiAccount -> showAnnouncementIfNeverShown(announcement) } } @@ -44,16 +45,22 @@ class DefaultAnnouncementService( return combine( flowOf(Unit), announcementStore.announcementStatusFlow(Announcement.NewNotificationSound), - ) { _, newNotificationSoundStatus -> + announcementStore.announcementStatusFlow(Announcement.MultiAccount), + ) { _, newNotificationSoundStatus, multiAccountStatus -> buildList { if (newNotificationSoundStatus == AnnouncementStatus.Show) { add(Announcement.NewNotificationSound) } + // The multi-account announcement is pending until it is dismissed. Users who were already + // logged in when the feature became available still have the default NeverShown status. + if (multiAccountStatus != AnnouncementStatus.Shown) { + add(Announcement.MultiAccount) + } } } } - private suspend fun showFullscreenAnnouncement(announcement: Announcement.Fullscreen) { + private suspend fun showAnnouncementIfNeverShown(announcement: Announcement) { val currentValue = announcementStore.announcementStatusFlow(announcement).first() if (currentValue == AnnouncementStatus.NeverShown) { announcementStore.setAnnouncementStatus(announcement, AnnouncementStatus.Show) 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 2093baa8b3..2235285bcf 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 @@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.map private val spaceAnnouncementKey = intPreferencesKey("spaceAnnouncement") private val newNotificationSoundKey = intPreferencesKey("newNotificationSound") +private val multiAccountKey = intPreferencesKey("multiAccountAnnouncement") @ContributesBinding(AppScope::class) class DefaultAnnouncementStore( @@ -36,11 +37,15 @@ class DefaultAnnouncementStore( override fun announcementStatusFlow(announcement: Announcement): Flow { val key = announcement.toKey() // Default-status convention: Shown for one-shot intro/feature announcements that should - // NOT replay on first launch (Fullscreen.Space is disabled; NewNotificationSound is opted - // in by AppMigration08). + // NOT replay on first launch. + // - Fullscreen.Space is disabled + // - 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. val defaultStatus = when (announcement) { Announcement.Fullscreen.Space -> AnnouncementStatus.Shown Announcement.NewNotificationSound -> AnnouncementStatus.Shown + Announcement.MultiAccount -> AnnouncementStatus.NeverShown } return store.data.map { prefs -> val ordinal = prefs[key] ?: defaultStatus.ordinal @@ -56,4 +61,5 @@ class DefaultAnnouncementStore( private fun Announcement.toKey() = when (this) { Announcement.Fullscreen.Space -> spaceAnnouncementKey Announcement.NewNotificationSound -> newNotificationSoundKey + Announcement.MultiAccount -> multiAccountKey } 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 c72d147c1d..18442b362d 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 @@ -51,6 +51,22 @@ class DefaultAnnouncementServiceTest { assertThat(announcementStore.announcementStatusFlow(Announcement.NewNotificationSound).first()).isEqualTo(AnnouncementStatus.Show) } + @Test + fun `when showing MultiAccount 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.MultiAccount).first()).isEqualTo(AnnouncementStatus.NeverShown) + sut.showAnnouncement(Announcement.MultiAccount) + assertThat(announcementStore.announcementStatusFlow(Announcement.MultiAccount).first()).isEqualTo(AnnouncementStatus.Show) + // Simulate user close the announcement + sut.onAnnouncementDismissed(Announcement.MultiAccount) + // Calling again showAnnouncement should not change the value + sut.showAnnouncement(Announcement.MultiAccount) + assertThat(announcementStore.announcementStatusFlow(Announcement.MultiAccount).first()).isEqualTo(AnnouncementStatus.Shown) + } + @Test fun `test announcementsToShowFlow`() = runTest { val announcementStore = InMemoryAnnouncementStore() @@ -58,10 +74,16 @@ class DefaultAnnouncementServiceTest { announcementStore = announcementStore, ) sut.announcementsToShowFlow().test { - assertThat(awaitItem()).isEmpty() + // MultiAccount is pending by default + assertThat(awaitItem()).containsExactly(Announcement.MultiAccount) announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show) - assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound) + assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount) + announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Show) + // Still pending + assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount) announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Shown) + assertThat(awaitItem()).containsExactly(Announcement.MultiAccount) + announcementStore.setAnnouncementStatus(Announcement.MultiAccount, 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 ed6dfec850..9b5e762eb6 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 @@ -16,9 +16,11 @@ import kotlinx.coroutines.flow.asStateFlow class InMemoryAnnouncementStore( initialFullscreenAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, initialNewNotificationSoundAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, + initialMultiAccountAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, ) : AnnouncementStore { private val fullScreenAnnouncement = MutableStateFlow(initialFullscreenAnnouncementStatus) private val newNotificationSoundAnnouncement = MutableStateFlow(initialNewNotificationSoundAnnouncementStatus) + private val multiAccountAnnouncement = MutableStateFlow(initialMultiAccountAnnouncementStatus) override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStatus) { announcement.toMutableStateFlow().value = status @@ -31,10 +33,12 @@ class InMemoryAnnouncementStore( override suspend fun reset() { fullScreenAnnouncement.value = AnnouncementStatus.NeverShown newNotificationSoundAnnouncement.value = AnnouncementStatus.NeverShown + multiAccountAnnouncement.value = AnnouncementStatus.NeverShown } private fun Announcement.toMutableStateFlow() = when (this) { is Announcement.Fullscreen -> fullScreenAnnouncement Announcement.NewNotificationSound -> newNotificationSoundAnnouncement + Announcement.MultiAccount -> multiAccountAnnouncement } } diff --git a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt index 1c6a0a906e..486a8bafce 100644 --- a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt +++ b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt @@ -32,5 +32,6 @@ interface HomeEntryPoint : FeatureEntryPoint { fun navigateToEnterRecoveryKey() fun navigateToRoomSettings(roomId: RoomId) fun navigateToBugReport() + fun navigateToAddAccount() } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt index 7d2bb09d30..6d1eec3dff 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt @@ -13,4 +13,5 @@ import io.element.android.libraries.matrix.api.core.SessionId sealed interface HomeEvent { data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvent data class SwitchToAccount(val sessionId: SessionId) : HomeEvent + data object DismissMultiAccountAnnouncement : HomeEvent } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt index 74dd6e441d..a481171e97 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt @@ -221,6 +221,7 @@ class HomeFlowNode( homeState = state, onRoomClick = ::navigateToRoom, onSettingsClick = callback::navigateToSettings, + onAddAccountClick = callback::navigateToAddAccount, onStartChatClick = callback::navigateToCreateRoom, onCreateSpaceClick = callback::navigateToCreateSpace, onSetUpRecoveryClick = callback::navigateToSetUpRecovery, 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 f0bcedbb43..95c98a4aec 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 @@ -19,18 +19,23 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import dev.zacsweers.metro.Inject +import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.home.impl.roomlist.RoomListState import io.element.android.features.home.impl.spaces.HomeSpacesState 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.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.indicator.api.IndicatorService 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.flow.combine +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch @Inject @@ -43,6 +48,8 @@ class HomePresenter( private val homeSpacesPresenter: Presenter, private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, private val sessionStore: SessionStore, + private val announcementService: AnnouncementService, + private val featureFlagService: FeatureFlagService, ) : Presenter { private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder() @@ -59,6 +66,30 @@ class HomePresenter( }.collectAsState(initial = persistentListOf(matrixUser)) val isOnline by syncService.isOnline.collectAsState() val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false) + val showMultiAccountAnnouncement by remember { + combine( + announcementService.announcementsToShowFlow(), + featureFlagService.isFeatureEnabledFlow(FeatureFlags.MultiAccount), + sessionStore.sessionsFlow(), + ) { announcements, isMultiAccountEnabled, sessions -> + // Do not announce the feature to users who already have several accounts + isMultiAccountEnabled && sessions.size == 1 && announcements.contains(Announcement.MultiAccount) + } + }.collectAsState(false) + LaunchedEffect(Unit) { + // The user already uses several accounts, so the feature does not need to be announced anymore, + // even if they later remove accounts. + combine( + announcementService.announcementsToShowFlow(), + sessionStore.sessionsFlow(), + ) { announcements, sessions -> + announcements.contains(Announcement.MultiAccount) && sessions.size > 1 + } + .filter { it } + .collect { + announcementService.onAnnouncementDismissed(Announcement.MultiAccount) + } + } val roomListState = roomListPresenter.present() val homeSpacesState = homeSpacesPresenter.present() var currentHomeNavigationBarItemOrdinal by rememberSaveable { mutableIntStateOf(HomeNavigationBarItem.Chats.ordinal) } @@ -82,6 +113,9 @@ class HomePresenter( is HomeEvent.SwitchToAccount -> coroutineState.launch { sessionStore.setLatestSession(event.sessionId.value) } + HomeEvent.DismissMultiAccountAnnouncement -> coroutineState.launch { + announcementService.onAnnouncementDismissed(Announcement.MultiAccount) + } } } @@ -95,6 +129,7 @@ class HomePresenter( homeSpacesState = homeSpacesState, snackbarMessage = snackbarMessage, canReportBug = canReportBug, + showMultiAccountAnnouncement = showMultiAccountAnnouncement, eventSink = ::handleEvent, ) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt index 462084aefb..a49d3876e9 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt @@ -28,6 +28,7 @@ data class HomeState( val homeSpacesState: HomeSpacesState, val snackbarMessage: SnackbarMessage?, val canReportBug: Boolean, + val showMultiAccountAnnouncement: Boolean, val eventSink: (HomeEvent) -> Unit, ) { val isBackHandlerEnabled = currentHomeNavigationBarItem != HomeNavigationBarItem.Chats || roomListState.spaceFiltersState is SpaceFiltersState.Selected diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt index f39942275a..f86c32c532 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt @@ -48,6 +48,8 @@ open class HomeStatePreviewParam : PreviewParameterProvider { homeSpacesState = aHomeSpacesState( spaceRooms = emptyList(), ), + ) + aHomeState( + showMultiAccountAnnouncement = true, ) } @@ -61,6 +63,7 @@ internal fun aHomeState( roomListState: RoomListState = aRoomListState(), homeSpacesState: HomeSpacesState = aHomeSpacesState(), canReportBug: Boolean = true, + showMultiAccountAnnouncement: Boolean = false, eventSink: (HomeEvent) -> Unit = {} ) = HomeState( currentUserAndNeighbors = currentUserAndNeighbors.toImmutableList(), @@ -68,6 +71,7 @@ internal fun aHomeState( hasNetworkConnection = hasNetworkConnection, snackbarMessage = snackbarMessage, canReportBug = canReportBug, + showMultiAccountAnnouncement = showMultiAccountAnnouncement, currentHomeNavigationBarItem = currentHomeNavigationBarItem, roomListState = roomListState, homeSpacesState = homeSpacesState, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt index bcc3229598..6ff31a7c12 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.zIndex import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.home.impl.components.HomeTopBar +import io.element.android.features.home.impl.components.MultiAccountAnnouncementBottomSheet import io.element.android.features.home.impl.components.RoomListContentView import io.element.android.features.home.impl.components.RoomListMenuAction import io.element.android.features.home.impl.model.RoomListRoomSummary @@ -76,6 +77,7 @@ fun HomeView( homeState: HomeState, onRoomClick: (RoomId, EventId?) -> Unit, onSettingsClick: () -> Unit, + onAddAccountClick: () -> Unit, onSetUpRecoveryClick: () -> Unit, onConfirmRecoveryKeyClick: () -> Unit, onStartChatClick: () -> Unit, @@ -110,6 +112,13 @@ fun HomeView( ) } + if (homeState.showMultiAccountAnnouncement) { + MultiAccountAnnouncementBottomSheet( + onDismiss = { homeState.eventSink(HomeEvent.DismissMultiAccountAnnouncement) }, + onAddAccountClick = { if (firstThrottler.canHandle()) onAddAccountClick() }, + ) + } + leaveRoomView() HomeScaffold( @@ -366,6 +375,7 @@ internal fun HomeViewPreview(@PreviewParameter(HomeStatePreviewParam::class) sta homeState = state, onRoomClick = { _, _ -> }, onSettingsClick = {}, + onAddAccountClick = {}, onSetUpRecoveryClick = {}, onConfirmRecoveryKeyClick = {}, onStartChatClick = {}, @@ -386,6 +396,7 @@ internal fun HomeViewA11yPreview() = ElementPreview { homeState = aHomeState(), onRoomClick = { _, _ -> }, onSettingsClick = {}, + onAddAccountClick = {}, onSetUpRecoveryClick = {}, onConfirmRecoveryKeyClick = {}, onStartChatClick = {}, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/MultiAccountAnnouncementBottomSheet.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/MultiAccountAnnouncementBottomSheet.kt new file mode 100644 index 0000000000..cec1dbf3a0 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/MultiAccountAnnouncementBottomSheet.kt @@ -0,0 +1,128 @@ +/* + * 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.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.home.impl.R +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.annotatedTextWithBold +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.launch + +/** + * Bottom sheet announcing that several accounts can now be added. + * Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=195-43073 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun MultiAccountAnnouncementBottomSheet( + onDismiss: () -> Unit, + onAddAccountClick: () -> Unit, +) { + val sheetState = rememberBottomSheetState( + initialValue = SheetValue.Hidden, + ) + val coroutineScope = rememberCoroutineScope() + ModalBottomSheet( + sheetState = sheetState, + onDismissRequest = onDismiss, + scrollable = false, + ) { + MultiAccountAnnouncementContent( + onAddAccountClick = { + coroutineScope.launch { + sheetState.hide() + onDismiss() + onAddAccountClick() + } + }, + ) + } +} + +@Composable +private fun MultiAccountAnnouncementContent( + onAddAccountClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(all = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + BigIcon( + style = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()), + ) + Spacer(Modifier.height(16.dp)) + Text( + modifier = Modifier + .padding(horizontal = 8.dp) + .semantics { heading() }, + text = stringResource(R.string.screen_multi_account_announcement_title), + style = ElementTheme.typography.fontHeadingMdBold, + color = ElementTheme.colors.textPrimary, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + val path = stringResource(CommonStrings.common_settings) + " > " + stringResource(CommonStrings.common_add_another_account) + Text( + modifier = Modifier.padding(horizontal = 8.dp), + text = annotatedTextWithBold( + text = stringResource(R.string.screen_multi_account_announcement_description, path), + boldText = path, + ), + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(40.dp)) + Button( + text = stringResource(R.string.screen_multi_account_announcement_action), + leadingIcon = IconSource.Vector(CompoundIcons.Plus()), + modifier = Modifier.fillMaxWidth(), + onClick = onAddAccountClick, + ) + Spacer(Modifier.height(16.dp)) + } +} + +@PreviewsDayNight +@Composable +internal fun MultiAccountAnnouncementBottomSheetPreview() = ElementPreview(fillMaxSize = true) { + MultiAccountAnnouncementBottomSheet( + onDismiss = {}, + onAddAccountClick = {}, + ) +} diff --git a/features/home/impl/src/main/res/values/localazy.xml b/features/home/impl/src/main/res/values/localazy.xml index b3c14354f5..4f3420fa23 100644 --- a/features/home/impl/src/main/res/values/localazy.xml +++ b/features/home/impl/src/main/res/values/localazy.xml @@ -24,6 +24,9 @@ "%1$s (%2$s) invited you" "This is a one time process, thanks for waiting." "Setting up your account." + "Add account" + "Go to %1$s to sign into another account. Then you can easily switch between them." + "You can now add multiple accounts" "Create a new conversation or room" "Clear filters" "Get started by messaging someone." diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt index cf9336b6ea..643de32ecb 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt @@ -52,6 +52,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() { override fun navigateToEnterRecoveryKey() = lambdaError() override fun navigateToRoomSettings(roomId: RoomId) = lambdaError() override fun navigateToBugReport() = lambdaError() + override fun navigateToAddAccount() = lambdaError() } val result = entryPoint.createNode( parentNode = parentNode, 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 a5553352db..d4b03b68c7 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 @@ -9,12 +9,18 @@ package io.element.android.features.home.impl import com.google.common.truth.Truth.assertThat +import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.home.impl.roomlist.aRoomListState import io.element.android.features.home.impl.spaces.HomeSpacesState import io.element.android.features.home.impl.spaces.aHomeSpacesState import io.element.android.features.rageshake.api.RageshakeFeatureAvailability +import io.element.android.features.rageshake.test.logs.FakeAnnouncementService import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.indicator.api.IndicatorService import io.element.android.libraries.indicator.test.FakeIndicatorService import io.element.android.libraries.matrix.api.MatrixClient @@ -30,6 +36,8 @@ 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.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.flow.flowOf import kotlinx.coroutines.test.runTest @@ -144,6 +152,99 @@ class HomePresenterTest { assertThat(finalState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Spaces) } } + + @Test + fun `present - multi account announcement is shown when announced, feature enabled and a single account`() = runTest { + val presenter = createHomePresenter( + sessionStore = InMemorySessionStore( + initialList = listOf(aSessionData()), + updateUserProfileResult = { _, _, _, _ -> }, + ), + announcementService = FakeAnnouncementService( + initialAnnouncementsToShowFlowValue = listOf(Announcement.MultiAccount), + ), + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.MultiAccount.key to true), + ), + ) + presenter.test { + assertThat(awaitItem().showMultiAccountAnnouncement).isFalse() + assertThat(expectMostRecentItem().showMultiAccountAnnouncement).isTrue() + } + } + + @Test + fun `present - multi account announcement is not shown when the feature is disabled`() = runTest { + val presenter = createHomePresenter( + sessionStore = InMemorySessionStore( + initialList = listOf(aSessionData()), + updateUserProfileResult = { _, _, _, _ -> }, + ), + announcementService = FakeAnnouncementService( + initialAnnouncementsToShowFlowValue = listOf(Announcement.MultiAccount), + ), + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.MultiAccount.key to false), + ), + ) + presenter.test { + assertThat(expectMostRecentItem().showMultiAccountAnnouncement).isFalse() + } + } + + @Test + fun `present - multi account announcement is not shown and is dismissed when there are several accounts`() = runTest { + val onAnnouncementDismissedResult = lambdaRecorder { } + val presenter = createHomePresenter( + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = "@alice:server.org"), + aSessionData(sessionId = "@bob:server.org"), + ), + updateUserProfileResult = { _, _, _, _ -> }, + ), + announcementService = FakeAnnouncementService( + initialAnnouncementsToShowFlowValue = listOf(Announcement.MultiAccount), + onAnnouncementDismissedResult = onAnnouncementDismissedResult, + ), + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.MultiAccount.key to true), + ), + ) + presenter.test { + assertThat(expectMostRecentItem().showMultiAccountAnnouncement).isFalse() + onAnnouncementDismissedResult.assertions().isCalledOnce() + .with(value(Announcement.MultiAccount)) + } + } + + @Test + fun `present - dismiss multi account announcement`() = runTest { + val onAnnouncementDismissedResult = lambdaRecorder { } + val announcementService = FakeAnnouncementService( + initialAnnouncementsToShowFlowValue = listOf(Announcement.MultiAccount), + onAnnouncementDismissedResult = onAnnouncementDismissedResult, + ) + val presenter = createHomePresenter( + sessionStore = InMemorySessionStore( + initialList = listOf(aSessionData()), + updateUserProfileResult = { _, _, _, _ -> }, + ), + announcementService = announcementService, + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.MultiAccount.key to true), + ), + ) + presenter.test { + val state = expectMostRecentItem() + assertThat(state.showMultiAccountAnnouncement).isTrue() + state.eventSink(HomeEvent.DismissMultiAccountAnnouncement) + announcementService.emitAnnouncementsToShow(emptyList()) + assertThat(awaitItem().showMultiAccountAnnouncement).isFalse() + onAnnouncementDismissedResult.assertions().isCalledOnce() + .with(value(Announcement.MultiAccount)) + } + } } internal fun createHomePresenter( @@ -154,6 +255,8 @@ internal fun createHomePresenter( indicatorService: IndicatorService = FakeIndicatorService(), homeSpacesPresenter: Presenter = Presenter { aHomeSpacesState() }, sessionStore: SessionStore = InMemorySessionStore(), + announcementService: AnnouncementService = FakeAnnouncementService(), + featureFlagService: FeatureFlagService = FakeFeatureFlagService(), ) = HomePresenter( client = client, syncService = syncService, @@ -163,4 +266,6 @@ internal fun createHomePresenter( homeSpacesPresenter = homeSpacesPresenter, rageshakeFeatureAvailability = rageshakeFeatureAvailability, sessionStore = sessionStore, + announcementService = announcementService, + featureFlagService = featureFlagService, ) 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 bd18cd903e..3cff7ca28b 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 @@ -19,6 +19,7 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import io.element.android.features.home.impl.HomeEvent import io.element.android.features.home.impl.HomeView import io.element.android.features.home.impl.R import io.element.android.features.home.impl.aHomeState @@ -259,6 +260,23 @@ class RoomListViewTest : RobolectricTest() { ) ) } + + @Test + fun `clicking on add account in the multi-account announcement emits the expected Event and invokes the callback`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + ensureCalledOnce { callback -> + setRoomListView( + state = aRoomListState(), + showMultiAccountAnnouncement = true, + homeEventSink = eventsRecorder, + onAddAccountClick = callback, + ) + clickOn(R.string.screen_multi_account_announcement_action) + // Wait for the bottom sheet to be hidden + waitForIdle() + } + eventsRecorder.assertSingle(HomeEvent.DismissMultiAccountAnnouncement) + } } private fun AndroidComposeUiTest.setRoomListView( @@ -273,12 +291,20 @@ private fun AndroidComposeUiTest.setRoomListView( onMenuActionClick: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(), onReportRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(), onDeclineInviteAndBlockUser: (RoomListRoomSummary) -> Unit = EnsureNeverCalledWithParam(), + onAddAccountClick: () -> Unit = EnsureNeverCalled(), + showMultiAccountAnnouncement: Boolean = false, + homeEventSink: (HomeEvent) -> Unit = {}, ) { setSafeContent { HomeView( - homeState = aHomeState(roomListState = state), + homeState = aHomeState( + roomListState = state, + showMultiAccountAnnouncement = showMultiAccountAnnouncement, + eventSink = homeEventSink, + ), onRoomClick = { roomId, _ -> onRoomClick(roomId) }, onSettingsClick = onSettingsClick, + onAddAccountClick = onAddAccountClick, onSetUpRecoveryClick = onSetUpRecoveryClick, onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, onStartChatClick = onCreateRoomClick, diff --git a/features/preferences/impl/src/main/res/values/localazy.xml b/features/preferences/impl/src/main/res/values/localazy.xml index 6e70c9b685..c702ac62a3 100644 --- a/features/preferences/impl/src/main/res/values/localazy.xml +++ b/features/preferences/impl/src/main/res/values/localazy.xml @@ -110,6 +110,7 @@ If you proceed, some of your settings may change." "system settings" "System notifications turned off" "Notifications" + "Add account" "Away" "Be right back" "Custom…" diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 102c627d90..0eeb366d72 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -247,6 +247,7 @@ "Call started" "You declined a call" "Chat backup" + "Chats" "Code block" "Copied to clipboard" "Copyright" @@ -287,6 +288,7 @@ Reason: %1$s." "File saved" "File saved to Downloads" "Forward message" + "Forward to" "Frequently used" "Gallery" @@ -299,6 +301,7 @@ Reason: %1$s." "GIF" "Group call in progress" + "Rooms" "Image" "In reply to %1$s" "Install APK" diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_MultiAccountAnnouncementBottomSheet_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_MultiAccountAnnouncementBottomSheet_Day_0_en.png new file mode 100644 index 0000000000..747c2f7197 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_MultiAccountAnnouncementBottomSheet_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:69f16135c4bfcddac589989018f053f3a09b6111a55370915ccaf2e8837c89e7 +size 33204 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_MultiAccountAnnouncementBottomSheet_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_MultiAccountAnnouncementBottomSheet_Night_0_en.png new file mode 100644 index 0000000000..1d11c81145 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_MultiAccountAnnouncementBottomSheet_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8cf546530844f35beb6efa6f1b6874dcf95d3079889a1ea21702d14266ca81e0 +size 31449 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 new file mode 100644 index 0000000000..342d1ab6ff --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_17_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b5c44832e3d4cbb637d48abc19174a66bdff343de3e60aa94c3d6bfaac44e78d +size 62363 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 new file mode 100644 index 0000000000..a2a191b30f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_17_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:513e23929e81026a24cb2d964fd8a65ba4998ac715021d480d7e7211d6a48d0a +size 59148 diff --git a/tools/localazy/config.json b/tools/localazy/config.json index 40b4ab299e..e7f5f1e036 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -199,6 +199,7 @@ "screen\\.home\\..*", "screen_roomlist_.*", "screen\\.roomlist\\..*", + "screen\\.multi_account_announcement\\..*", "session_verification_banner_.*", "confirm_recovery_key_banner_.*", "banner\\.set_up_recovery\\..*",