mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-03 16:10:00 +00:00
Announce multi-account support
Show a one-time bottom sheet on the home screen to announce that several accounts can now be added (element-hq/element-meta#3349). - Add `Announcement.MultiAccount`, which is never shown by default. - AppMigration11 queues the announcement on application upgrade only, and marks it as shown if the user already has several accounts. - HomePresenter shows the bottom sheet only when the announcement is pending, the MultiAccount feature flag is enabled and the user has a single account. - The "Add account" button hides the sheet, marks the announcement as dismissed and starts the add account flow through the new `HomeEntryPoint.Callback.navigateToAddAccount()`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -359,6 +359,10 @@ class LoggedInFlowNode(
|
|||||||
override fun navigateToBugReport() {
|
override fun navigateToBugReport() {
|
||||||
callback.navigateToBugReport()
|
callback.navigateToBugReport()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun navigateToAddAccount() {
|
||||||
|
callback.navigateToAddAccount()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
homeEntryPoint.createNode(
|
homeEntryPoint.createNode(
|
||||||
parentNode = this,
|
parentNode = this,
|
||||||
|
|||||||
+2
@@ -17,4 +17,6 @@ sealed interface Announcement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data object NewNotificationSound : Announcement
|
data object NewNotificationSound : Announcement
|
||||||
|
|
||||||
|
data object MultiAccount : Announcement
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-3
@@ -29,10 +29,11 @@ class DefaultAnnouncementService(
|
|||||||
) : AnnouncementService {
|
) : AnnouncementService {
|
||||||
override suspend fun showAnnouncement(announcement: Announcement) {
|
override suspend fun showAnnouncement(announcement: Announcement) {
|
||||||
when (announcement) {
|
when (announcement) {
|
||||||
is Announcement.Fullscreen -> showFullscreenAnnouncement(announcement)
|
is Announcement.Fullscreen -> showAnnouncementIfNeverShown(announcement)
|
||||||
Announcement.NewNotificationSound -> {
|
Announcement.NewNotificationSound -> {
|
||||||
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show)
|
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show)
|
||||||
}
|
}
|
||||||
|
Announcement.MultiAccount -> showAnnouncementIfNeverShown(announcement)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,16 +45,20 @@ class DefaultAnnouncementService(
|
|||||||
return combine(
|
return combine(
|
||||||
flowOf(Unit),
|
flowOf(Unit),
|
||||||
announcementStore.announcementStatusFlow(Announcement.NewNotificationSound),
|
announcementStore.announcementStatusFlow(Announcement.NewNotificationSound),
|
||||||
) { _, newNotificationSoundStatus ->
|
announcementStore.announcementStatusFlow(Announcement.MultiAccount),
|
||||||
|
) { _, newNotificationSoundStatus, multiAccountStatus ->
|
||||||
buildList {
|
buildList {
|
||||||
if (newNotificationSoundStatus == AnnouncementStatus.Show) {
|
if (newNotificationSoundStatus == AnnouncementStatus.Show) {
|
||||||
add(Announcement.NewNotificationSound)
|
add(Announcement.NewNotificationSound)
|
||||||
}
|
}
|
||||||
|
if (multiAccountStatus == AnnouncementStatus.Show) {
|
||||||
|
add(Announcement.MultiAccount)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun showFullscreenAnnouncement(announcement: Announcement.Fullscreen) {
|
private suspend fun showAnnouncementIfNeverShown(announcement: Announcement) {
|
||||||
val currentValue = announcementStore.announcementStatusFlow(announcement).first()
|
val currentValue = announcementStore.announcementStatusFlow(announcement).first()
|
||||||
if (currentValue == AnnouncementStatus.NeverShown) {
|
if (currentValue == AnnouncementStatus.NeverShown) {
|
||||||
announcementStore.setAnnouncementStatus(announcement, AnnouncementStatus.Show)
|
announcementStore.setAnnouncementStatus(announcement, AnnouncementStatus.Show)
|
||||||
|
|||||||
+7
-2
@@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.map
|
|||||||
|
|
||||||
private val spaceAnnouncementKey = intPreferencesKey("spaceAnnouncement")
|
private val spaceAnnouncementKey = intPreferencesKey("spaceAnnouncement")
|
||||||
private val newNotificationSoundKey = intPreferencesKey("newNotificationSound")
|
private val newNotificationSoundKey = intPreferencesKey("newNotificationSound")
|
||||||
|
private val multiAccountKey = intPreferencesKey("multiAccountAnnouncement")
|
||||||
|
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
class DefaultAnnouncementStore(
|
class DefaultAnnouncementStore(
|
||||||
@@ -36,11 +37,14 @@ class DefaultAnnouncementStore(
|
|||||||
override fun announcementStatusFlow(announcement: Announcement): Flow<AnnouncementStatus> {
|
override fun announcementStatusFlow(announcement: Announcement): Flow<AnnouncementStatus> {
|
||||||
val key = announcement.toKey()
|
val key = announcement.toKey()
|
||||||
// Default-status convention: Shown for one-shot intro/feature announcements that should
|
// Default-status convention: Shown for one-shot intro/feature announcements that should
|
||||||
// NOT replay on first launch (Fullscreen.Space is disabled; NewNotificationSound is opted
|
// NOT replay on first launch.
|
||||||
// in by AppMigration08).
|
// - Fullscreen.Space is disabled
|
||||||
|
// - NewNotificationSound is opted in by AppMigration08
|
||||||
|
// - MultiAccount is opted in by AppMigration11
|
||||||
val defaultStatus = when (announcement) {
|
val defaultStatus = when (announcement) {
|
||||||
Announcement.Fullscreen.Space -> AnnouncementStatus.Shown
|
Announcement.Fullscreen.Space -> AnnouncementStatus.Shown
|
||||||
Announcement.NewNotificationSound -> AnnouncementStatus.Shown
|
Announcement.NewNotificationSound -> AnnouncementStatus.Shown
|
||||||
|
Announcement.MultiAccount -> AnnouncementStatus.Shown
|
||||||
}
|
}
|
||||||
return store.data.map { prefs ->
|
return store.data.map { prefs ->
|
||||||
val ordinal = prefs[key] ?: defaultStatus.ordinal
|
val ordinal = prefs[key] ?: defaultStatus.ordinal
|
||||||
@@ -56,4 +60,5 @@ class DefaultAnnouncementStore(
|
|||||||
private fun Announcement.toKey() = when (this) {
|
private fun Announcement.toKey() = when (this) {
|
||||||
Announcement.Fullscreen.Space -> spaceAnnouncementKey
|
Announcement.Fullscreen.Space -> spaceAnnouncementKey
|
||||||
Announcement.NewNotificationSound -> newNotificationSoundKey
|
Announcement.NewNotificationSound -> newNotificationSoundKey
|
||||||
|
Announcement.MultiAccount -> multiAccountKey
|
||||||
}
|
}
|
||||||
|
|||||||
+20
@@ -51,6 +51,22 @@ class DefaultAnnouncementServiceTest {
|
|||||||
assertThat(announcementStore.announcementStatusFlow(Announcement.NewNotificationSound).first()).isEqualTo(AnnouncementStatus.Show)
|
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
|
@Test
|
||||||
fun `test announcementsToShowFlow`() = runTest {
|
fun `test announcementsToShowFlow`() = runTest {
|
||||||
val announcementStore = InMemoryAnnouncementStore()
|
val announcementStore = InMemoryAnnouncementStore()
|
||||||
@@ -61,7 +77,11 @@ class DefaultAnnouncementServiceTest {
|
|||||||
assertThat(awaitItem()).isEmpty()
|
assertThat(awaitItem()).isEmpty()
|
||||||
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show)
|
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show)
|
||||||
assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound)
|
assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound)
|
||||||
|
announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Show)
|
||||||
|
assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount)
|
||||||
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Shown)
|
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Shown)
|
||||||
|
assertThat(awaitItem()).containsExactly(Announcement.MultiAccount)
|
||||||
|
announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Shown)
|
||||||
assertThat(awaitItem()).isEmpty()
|
assertThat(awaitItem()).isEmpty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -16,9 +16,11 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
class InMemoryAnnouncementStore(
|
class InMemoryAnnouncementStore(
|
||||||
initialFullscreenAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
|
initialFullscreenAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
|
||||||
initialNewNotificationSoundAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
|
initialNewNotificationSoundAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
|
||||||
|
initialMultiAccountAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
|
||||||
) : AnnouncementStore {
|
) : AnnouncementStore {
|
||||||
private val fullScreenAnnouncement = MutableStateFlow(initialFullscreenAnnouncementStatus)
|
private val fullScreenAnnouncement = MutableStateFlow(initialFullscreenAnnouncementStatus)
|
||||||
private val newNotificationSoundAnnouncement = MutableStateFlow(initialNewNotificationSoundAnnouncementStatus)
|
private val newNotificationSoundAnnouncement = MutableStateFlow(initialNewNotificationSoundAnnouncementStatus)
|
||||||
|
private val multiAccountAnnouncement = MutableStateFlow(initialMultiAccountAnnouncementStatus)
|
||||||
|
|
||||||
override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStatus) {
|
override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStatus) {
|
||||||
announcement.toMutableStateFlow().value = status
|
announcement.toMutableStateFlow().value = status
|
||||||
@@ -31,10 +33,12 @@ class InMemoryAnnouncementStore(
|
|||||||
override suspend fun reset() {
|
override suspend fun reset() {
|
||||||
fullScreenAnnouncement.value = AnnouncementStatus.NeverShown
|
fullScreenAnnouncement.value = AnnouncementStatus.NeverShown
|
||||||
newNotificationSoundAnnouncement.value = AnnouncementStatus.NeverShown
|
newNotificationSoundAnnouncement.value = AnnouncementStatus.NeverShown
|
||||||
|
multiAccountAnnouncement.value = AnnouncementStatus.NeverShown
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Announcement.toMutableStateFlow() = when (this) {
|
private fun Announcement.toMutableStateFlow() = when (this) {
|
||||||
is Announcement.Fullscreen -> fullScreenAnnouncement
|
is Announcement.Fullscreen -> fullScreenAnnouncement
|
||||||
Announcement.NewNotificationSound -> newNotificationSoundAnnouncement
|
Announcement.NewNotificationSound -> newNotificationSoundAnnouncement
|
||||||
|
Announcement.MultiAccount -> multiAccountAnnouncement
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -32,5 +32,6 @@ interface HomeEntryPoint : FeatureEntryPoint {
|
|||||||
fun navigateToEnterRecoveryKey()
|
fun navigateToEnterRecoveryKey()
|
||||||
fun navigateToRoomSettings(roomId: RoomId)
|
fun navigateToRoomSettings(roomId: RoomId)
|
||||||
fun navigateToBugReport()
|
fun navigateToBugReport()
|
||||||
|
fun navigateToAddAccount()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ import io.element.android.libraries.matrix.api.core.SessionId
|
|||||||
sealed interface HomeEvent {
|
sealed interface HomeEvent {
|
||||||
data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvent
|
data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvent
|
||||||
data class SwitchToAccount(val sessionId: SessionId) : HomeEvent
|
data class SwitchToAccount(val sessionId: SessionId) : HomeEvent
|
||||||
|
data object DismissMultiAccountAnnouncement : HomeEvent
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -221,6 +221,7 @@ class HomeFlowNode(
|
|||||||
homeState = state,
|
homeState = state,
|
||||||
onRoomClick = ::navigateToRoom,
|
onRoomClick = ::navigateToRoom,
|
||||||
onSettingsClick = callback::navigateToSettings,
|
onSettingsClick = callback::navigateToSettings,
|
||||||
|
onAddAccountClick = callback::navigateToAddAccount,
|
||||||
onStartChatClick = callback::navigateToCreateRoom,
|
onStartChatClick = callback::navigateToCreateRoom,
|
||||||
onCreateSpaceClick = callback::navigateToCreateSpace,
|
onCreateSpaceClick = callback::navigateToCreateSpace,
|
||||||
onSetUpRecoveryClick = callback::navigateToSetUpRecovery,
|
onSetUpRecoveryClick = callback::navigateToSetUpRecovery,
|
||||||
|
|||||||
+20
@@ -19,12 +19,16 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import dev.zacsweers.metro.Inject
|
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.roomlist.RoomListState
|
||||||
import io.element.android.features.home.impl.spaces.HomeSpacesState
|
import io.element.android.features.home.impl.spaces.HomeSpacesState
|
||||||
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
|
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
|
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.indicator.api.IndicatorService
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
import io.element.android.libraries.matrix.api.sync.SyncService
|
||||||
@@ -43,6 +47,8 @@ class HomePresenter(
|
|||||||
private val homeSpacesPresenter: Presenter<HomeSpacesState>,
|
private val homeSpacesPresenter: Presenter<HomeSpacesState>,
|
||||||
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
|
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
|
||||||
private val sessionStore: SessionStore,
|
private val sessionStore: SessionStore,
|
||||||
|
private val announcementService: AnnouncementService,
|
||||||
|
private val featureFlagService: FeatureFlagService,
|
||||||
) : Presenter<HomeState> {
|
) : Presenter<HomeState> {
|
||||||
private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder()
|
private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder()
|
||||||
|
|
||||||
@@ -59,6 +65,16 @@ class HomePresenter(
|
|||||||
}.collectAsState(initial = persistentListOf(matrixUser))
|
}.collectAsState(initial = persistentListOf(matrixUser))
|
||||||
val isOnline by syncService.isOnline.collectAsState()
|
val isOnline by syncService.isOnline.collectAsState()
|
||||||
val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false)
|
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)
|
||||||
val roomListState = roomListPresenter.present()
|
val roomListState = roomListPresenter.present()
|
||||||
val homeSpacesState = homeSpacesPresenter.present()
|
val homeSpacesState = homeSpacesPresenter.present()
|
||||||
var currentHomeNavigationBarItemOrdinal by rememberSaveable { mutableIntStateOf(HomeNavigationBarItem.Chats.ordinal) }
|
var currentHomeNavigationBarItemOrdinal by rememberSaveable { mutableIntStateOf(HomeNavigationBarItem.Chats.ordinal) }
|
||||||
@@ -82,6 +98,9 @@ class HomePresenter(
|
|||||||
is HomeEvent.SwitchToAccount -> coroutineState.launch {
|
is HomeEvent.SwitchToAccount -> coroutineState.launch {
|
||||||
sessionStore.setLatestSession(event.sessionId.value)
|
sessionStore.setLatestSession(event.sessionId.value)
|
||||||
}
|
}
|
||||||
|
HomeEvent.DismissMultiAccountAnnouncement -> coroutineState.launch {
|
||||||
|
announcementService.onAnnouncementDismissed(Announcement.MultiAccount)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +114,7 @@ class HomePresenter(
|
|||||||
homeSpacesState = homeSpacesState,
|
homeSpacesState = homeSpacesState,
|
||||||
snackbarMessage = snackbarMessage,
|
snackbarMessage = snackbarMessage,
|
||||||
canReportBug = canReportBug,
|
canReportBug = canReportBug,
|
||||||
|
showMultiAccountAnnouncement = showMultiAccountAnnouncement,
|
||||||
eventSink = ::handleEvent,
|
eventSink = ::handleEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ data class HomeState(
|
|||||||
val homeSpacesState: HomeSpacesState,
|
val homeSpacesState: HomeSpacesState,
|
||||||
val snackbarMessage: SnackbarMessage?,
|
val snackbarMessage: SnackbarMessage?,
|
||||||
val canReportBug: Boolean,
|
val canReportBug: Boolean,
|
||||||
|
val showMultiAccountAnnouncement: Boolean,
|
||||||
val eventSink: (HomeEvent) -> Unit,
|
val eventSink: (HomeEvent) -> Unit,
|
||||||
) {
|
) {
|
||||||
val isBackHandlerEnabled = currentHomeNavigationBarItem != HomeNavigationBarItem.Chats || roomListState.spaceFiltersState is SpaceFiltersState.Selected
|
val isBackHandlerEnabled = currentHomeNavigationBarItem != HomeNavigationBarItem.Chats || roomListState.spaceFiltersState is SpaceFiltersState.Selected
|
||||||
|
|||||||
+5
@@ -41,6 +41,9 @@ open class HomeStatePreviewParam : PreviewParameterProvider<HomeState> {
|
|||||||
aHomeState(
|
aHomeState(
|
||||||
currentHomeNavigationBarItem = HomeNavigationBarItem.Spaces,
|
currentHomeNavigationBarItem = HomeNavigationBarItem.Spaces,
|
||||||
),
|
),
|
||||||
|
aHomeState(
|
||||||
|
showMultiAccountAnnouncement = true,
|
||||||
|
),
|
||||||
) + RoomListStatePreviewParam().values.map {
|
) + RoomListStatePreviewParam().values.map {
|
||||||
aHomeState(roomListState = it)
|
aHomeState(roomListState = it)
|
||||||
} + aHomeState(
|
} + aHomeState(
|
||||||
@@ -61,6 +64,7 @@ internal fun aHomeState(
|
|||||||
roomListState: RoomListState = aRoomListState(),
|
roomListState: RoomListState = aRoomListState(),
|
||||||
homeSpacesState: HomeSpacesState = aHomeSpacesState(),
|
homeSpacesState: HomeSpacesState = aHomeSpacesState(),
|
||||||
canReportBug: Boolean = true,
|
canReportBug: Boolean = true,
|
||||||
|
showMultiAccountAnnouncement: Boolean = false,
|
||||||
eventSink: (HomeEvent) -> Unit = {}
|
eventSink: (HomeEvent) -> Unit = {}
|
||||||
) = HomeState(
|
) = HomeState(
|
||||||
currentUserAndNeighbors = currentUserAndNeighbors.toImmutableList(),
|
currentUserAndNeighbors = currentUserAndNeighbors.toImmutableList(),
|
||||||
@@ -68,6 +72,7 @@ internal fun aHomeState(
|
|||||||
hasNetworkConnection = hasNetworkConnection,
|
hasNetworkConnection = hasNetworkConnection,
|
||||||
snackbarMessage = snackbarMessage,
|
snackbarMessage = snackbarMessage,
|
||||||
canReportBug = canReportBug,
|
canReportBug = canReportBug,
|
||||||
|
showMultiAccountAnnouncement = showMultiAccountAnnouncement,
|
||||||
currentHomeNavigationBarItem = currentHomeNavigationBarItem,
|
currentHomeNavigationBarItem = currentHomeNavigationBarItem,
|
||||||
roomListState = roomListState,
|
roomListState = roomListState,
|
||||||
homeSpacesState = homeSpacesState,
|
homeSpacesState = homeSpacesState,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import androidx.compose.ui.zIndex
|
|||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
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.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.RoomListContentView
|
||||||
import io.element.android.features.home.impl.components.RoomListMenuAction
|
import io.element.android.features.home.impl.components.RoomListMenuAction
|
||||||
import io.element.android.features.home.impl.model.RoomListRoomSummary
|
import io.element.android.features.home.impl.model.RoomListRoomSummary
|
||||||
@@ -76,6 +77,7 @@ fun HomeView(
|
|||||||
homeState: HomeState,
|
homeState: HomeState,
|
||||||
onRoomClick: (RoomId, EventId?) -> Unit,
|
onRoomClick: (RoomId, EventId?) -> Unit,
|
||||||
onSettingsClick: () -> Unit,
|
onSettingsClick: () -> Unit,
|
||||||
|
onAddAccountClick: () -> Unit,
|
||||||
onSetUpRecoveryClick: () -> Unit,
|
onSetUpRecoveryClick: () -> Unit,
|
||||||
onConfirmRecoveryKeyClick: () -> Unit,
|
onConfirmRecoveryKeyClick: () -> Unit,
|
||||||
onStartChatClick: () -> Unit,
|
onStartChatClick: () -> Unit,
|
||||||
@@ -110,6 +112,13 @@ fun HomeView(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (homeState.showMultiAccountAnnouncement) {
|
||||||
|
MultiAccountAnnouncementBottomSheet(
|
||||||
|
onDismiss = { homeState.eventSink(HomeEvent.DismissMultiAccountAnnouncement) },
|
||||||
|
onAddAccountClick = { if (firstThrottler.canHandle()) onAddAccountClick() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
leaveRoomView()
|
leaveRoomView()
|
||||||
|
|
||||||
HomeScaffold(
|
HomeScaffold(
|
||||||
@@ -366,6 +375,7 @@ internal fun HomeViewPreview(@PreviewParameter(HomeStatePreviewParam::class) sta
|
|||||||
homeState = state,
|
homeState = state,
|
||||||
onRoomClick = { _, _ -> },
|
onRoomClick = { _, _ -> },
|
||||||
onSettingsClick = {},
|
onSettingsClick = {},
|
||||||
|
onAddAccountClick = {},
|
||||||
onSetUpRecoveryClick = {},
|
onSetUpRecoveryClick = {},
|
||||||
onConfirmRecoveryKeyClick = {},
|
onConfirmRecoveryKeyClick = {},
|
||||||
onStartChatClick = {},
|
onStartChatClick = {},
|
||||||
@@ -386,6 +396,7 @@ internal fun HomeViewA11yPreview() = ElementPreview {
|
|||||||
homeState = aHomeState(),
|
homeState = aHomeState(),
|
||||||
onRoomClick = { _, _ -> },
|
onRoomClick = { _, _ -> },
|
||||||
onSettingsClick = {},
|
onSettingsClick = {},
|
||||||
|
onAddAccountClick = {},
|
||||||
onSetUpRecoveryClick = {},
|
onSetUpRecoveryClick = {},
|
||||||
onConfirmRecoveryKeyClick = {},
|
onConfirmRecoveryKeyClick = {},
|
||||||
onStartChatClick = {},
|
onStartChatClick = {},
|
||||||
|
|||||||
+128
@@ -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 MultiAccountAnnouncementContentPreview() = ElementPreview(fillMaxSize = true) {
|
||||||
|
MultiAccountAnnouncementBottomSheet(
|
||||||
|
onDismiss = {},
|
||||||
|
onAddAccountClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -52,6 +52,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() {
|
|||||||
override fun navigateToEnterRecoveryKey() = lambdaError()
|
override fun navigateToEnterRecoveryKey() = lambdaError()
|
||||||
override fun navigateToRoomSettings(roomId: RoomId) = lambdaError()
|
override fun navigateToRoomSettings(roomId: RoomId) = lambdaError()
|
||||||
override fun navigateToBugReport() = lambdaError()
|
override fun navigateToBugReport() = lambdaError()
|
||||||
|
override fun navigateToAddAccount() = lambdaError()
|
||||||
}
|
}
|
||||||
val result = entryPoint.createNode(
|
val result = entryPoint.createNode(
|
||||||
parentNode = parentNode,
|
parentNode = parentNode,
|
||||||
|
|||||||
+101
@@ -9,12 +9,18 @@
|
|||||||
package io.element.android.features.home.impl
|
package io.element.android.features.home.impl
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
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.roomlist.aRoomListState
|
||||||
import io.element.android.features.home.impl.spaces.HomeSpacesState
|
import io.element.android.features.home.impl.spaces.HomeSpacesState
|
||||||
import io.element.android.features.home.impl.spaces.aHomeSpacesState
|
import io.element.android.features.home.impl.spaces.aHomeSpacesState
|
||||||
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
|
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.architecture.Presenter
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
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.api.IndicatorService
|
||||||
import io.element.android.libraries.indicator.test.FakeIndicatorService
|
import io.element.android.libraries.indicator.test.FakeIndicatorService
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
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.InMemorySessionStore
|
||||||
import io.element.android.libraries.sessionstorage.test.aSessionData
|
import io.element.android.libraries.sessionstorage.test.aSessionData
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
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 io.element.android.tests.testutils.test
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
@@ -144,6 +152,95 @@ class HomePresenterTest {
|
|||||||
assertThat(finalState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Spaces)
|
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 when there are several accounts`() = runTest {
|
||||||
|
val presenter = createHomePresenter(
|
||||||
|
sessionStore = InMemorySessionStore(
|
||||||
|
initialList = listOf(
|
||||||
|
aSessionData(sessionId = "@alice:server.org"),
|
||||||
|
aSessionData(sessionId = "@bob:server.org"),
|
||||||
|
),
|
||||||
|
updateUserProfileResult = { _, _, _, _ -> },
|
||||||
|
),
|
||||||
|
announcementService = FakeAnnouncementService(
|
||||||
|
initialAnnouncementsToShowFlowValue = listOf(Announcement.MultiAccount),
|
||||||
|
),
|
||||||
|
featureFlagService = FakeFeatureFlagService(
|
||||||
|
initialState = mapOf(FeatureFlags.MultiAccount.key to true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
assertThat(expectMostRecentItem().showMultiAccountAnnouncement).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - dismiss multi account announcement`() = runTest {
|
||||||
|
val onAnnouncementDismissedResult = lambdaRecorder<Announcement, Unit> { }
|
||||||
|
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(
|
internal fun createHomePresenter(
|
||||||
@@ -154,6 +251,8 @@ internal fun createHomePresenter(
|
|||||||
indicatorService: IndicatorService = FakeIndicatorService(),
|
indicatorService: IndicatorService = FakeIndicatorService(),
|
||||||
homeSpacesPresenter: Presenter<HomeSpacesState> = Presenter { aHomeSpacesState() },
|
homeSpacesPresenter: Presenter<HomeSpacesState> = Presenter { aHomeSpacesState() },
|
||||||
sessionStore: SessionStore = InMemorySessionStore(),
|
sessionStore: SessionStore = InMemorySessionStore(),
|
||||||
|
announcementService: AnnouncementService = FakeAnnouncementService(),
|
||||||
|
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
|
||||||
) = HomePresenter(
|
) = HomePresenter(
|
||||||
client = client,
|
client = client,
|
||||||
syncService = syncService,
|
syncService = syncService,
|
||||||
@@ -163,4 +262,6 @@ internal fun createHomePresenter(
|
|||||||
homeSpacesPresenter = homeSpacesPresenter,
|
homeSpacesPresenter = homeSpacesPresenter,
|
||||||
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
|
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
|
||||||
sessionStore = sessionStore,
|
sessionStore = sessionStore,
|
||||||
|
announcementService = announcementService,
|
||||||
|
featureFlagService = featureFlagService,
|
||||||
)
|
)
|
||||||
|
|||||||
+27
-1
@@ -19,6 +19,7 @@ import androidx.compose.ui.test.onNodeWithText
|
|||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.compose.ui.test.performTouchInput
|
import androidx.compose.ui.test.performTouchInput
|
||||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
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.HomeView
|
||||||
import io.element.android.features.home.impl.R
|
import io.element.android.features.home.impl.R
|
||||||
import io.element.android.features.home.impl.aHomeState
|
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<HomeEvent>()
|
||||||
|
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<ComponentActivity>.setRoomListView(
|
private fun AndroidComposeUiTest<ComponentActivity>.setRoomListView(
|
||||||
@@ -273,12 +291,20 @@ private fun AndroidComposeUiTest<ComponentActivity>.setRoomListView(
|
|||||||
onMenuActionClick: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(),
|
onMenuActionClick: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(),
|
||||||
onReportRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
|
onReportRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
|
||||||
onDeclineInviteAndBlockUser: (RoomListRoomSummary) -> Unit = EnsureNeverCalledWithParam(),
|
onDeclineInviteAndBlockUser: (RoomListRoomSummary) -> Unit = EnsureNeverCalledWithParam(),
|
||||||
|
onAddAccountClick: () -> Unit = EnsureNeverCalled(),
|
||||||
|
showMultiAccountAnnouncement: Boolean = false,
|
||||||
|
homeEventSink: (HomeEvent) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
setSafeContent {
|
setSafeContent {
|
||||||
HomeView(
|
HomeView(
|
||||||
homeState = aHomeState(roomListState = state),
|
homeState = aHomeState(
|
||||||
|
roomListState = state,
|
||||||
|
showMultiAccountAnnouncement = showMultiAccountAnnouncement,
|
||||||
|
eventSink = homeEventSink,
|
||||||
|
),
|
||||||
onRoomClick = { roomId, _ -> onRoomClick(roomId) },
|
onRoomClick = { roomId, _ -> onRoomClick(roomId) },
|
||||||
onSettingsClick = onSettingsClick,
|
onSettingsClick = onSettingsClick,
|
||||||
|
onAddAccountClick = onAddAccountClick,
|
||||||
onSetUpRecoveryClick = onSetUpRecoveryClick,
|
onSetUpRecoveryClick = onSetUpRecoveryClick,
|
||||||
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
|
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
|
||||||
onStartChatClick = onCreateRoomClick,
|
onStartChatClick = onCreateRoomClick,
|
||||||
|
|||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
* 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.migration.impl.migrations
|
||||||
|
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.ContributesIntoSet
|
||||||
|
import io.element.android.features.announcement.api.Announcement
|
||||||
|
import io.element.android.features.announcement.api.AnnouncementService
|
||||||
|
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the multi-account announcement is displayed, but only on application upgrade,
|
||||||
|
* and only if the user does not already have several accounts.
|
||||||
|
*/
|
||||||
|
@ContributesIntoSet(AppScope::class)
|
||||||
|
class AppMigration11(
|
||||||
|
private val sessionStore: SessionStore,
|
||||||
|
private val announcementService: AnnouncementService,
|
||||||
|
) : AppMigration {
|
||||||
|
override val order: Int = 11
|
||||||
|
|
||||||
|
override suspend fun migrate(isFreshInstall: Boolean) {
|
||||||
|
if (isFreshInstall) return
|
||||||
|
|
||||||
|
if (sessionStore.getAllSessions().size > 1) {
|
||||||
|
// The user already uses several accounts, no need to announce the feature
|
||||||
|
announcementService.onAnnouncementDismissed(Announcement.MultiAccount)
|
||||||
|
} else {
|
||||||
|
announcementService.showAnnouncement(Announcement.MultiAccount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
/*
|
||||||
|
* 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.migration.impl.migrations
|
||||||
|
|
||||||
|
import io.element.android.features.announcement.api.Announcement
|
||||||
|
import io.element.android.features.rageshake.test.logs.FakeAnnouncementService
|
||||||
|
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||||
|
import io.element.android.libraries.sessionstorage.test.aSessionData
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import io.element.android.tests.testutils.lambda.value
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AppMigration11Test {
|
||||||
|
@Test
|
||||||
|
fun `migration on fresh install should not invoke the AnnouncementService`() = runTest {
|
||||||
|
val service = FakeAnnouncementService()
|
||||||
|
val migration = AppMigration11(
|
||||||
|
sessionStore = InMemorySessionStore(initialList = listOf(aSessionData())),
|
||||||
|
announcementService = service,
|
||||||
|
)
|
||||||
|
// FakeAnnouncementService lambdas throw by default
|
||||||
|
migration.migrate(isFreshInstall = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `migration on upgrade with a single account should show the announcement`() = runTest {
|
||||||
|
val showAnnouncementResult = lambdaRecorder<Announcement, Unit> { }
|
||||||
|
val service = FakeAnnouncementService(
|
||||||
|
showAnnouncementResult = showAnnouncementResult,
|
||||||
|
)
|
||||||
|
val migration = AppMigration11(
|
||||||
|
sessionStore = InMemorySessionStore(initialList = listOf(aSessionData())),
|
||||||
|
announcementService = service,
|
||||||
|
)
|
||||||
|
migration.migrate(isFreshInstall = false)
|
||||||
|
showAnnouncementResult.assertions().isCalledOnce()
|
||||||
|
.with(value(Announcement.MultiAccount))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `migration on upgrade with several accounts should mark the announcement as dismissed`() = runTest {
|
||||||
|
val onAnnouncementDismissedResult = lambdaRecorder<Announcement, Unit> { }
|
||||||
|
val service = FakeAnnouncementService(
|
||||||
|
onAnnouncementDismissedResult = onAnnouncementDismissedResult,
|
||||||
|
)
|
||||||
|
val migration = AppMigration11(
|
||||||
|
sessionStore = InMemorySessionStore(
|
||||||
|
initialList = listOf(
|
||||||
|
aSessionData(sessionId = "@alice:server.org"),
|
||||||
|
aSessionData(sessionId = "@bob:server.org"),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
announcementService = service,
|
||||||
|
)
|
||||||
|
migration.migrate(isFreshInstall = false)
|
||||||
|
onAnnouncementDismissedResult.assertions().isCalledOnce()
|
||||||
|
.with(value(Announcement.MultiAccount))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user