diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index 7f487a1e63..063e26b9da 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -44,6 +44,7 @@ dependencies { implementation(libs.coil) + implementation(projects.features.announcement.api) implementation(projects.features.ftue.api) implementation(projects.features.share.api) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 7f5cb13246..c01b42af37 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -34,6 +34,7 @@ import io.element.android.appnav.intent.ResolvedIntent import io.element.android.appnav.root.RootNavStateFlowFactory import io.element.android.appnav.root.RootPresenter import io.element.android.appnav.root.RootView +import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.login.api.LoginParams import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint @@ -81,6 +82,7 @@ class RootFlowNode( private val oidcActionFlow: OidcActionFlow, private val bugReporter: BugReporter, private val featureFlagService: FeatureFlagService, + private val announcementService: AnnouncementService, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.SplashScreen, @@ -172,6 +174,9 @@ class RootFlowNode( state = state, modifier = modifier, onOpenBugReport = this::onOpenBugReport, + announcementRenderer = { state, announcementModifier -> + announcementService.Render(state, announcementModifier) + } ) { val backstackSlider = rememberBackstackSlider( transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) }, diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootPresenter.kt index d987c2a7ec..928e551a56 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/root/RootPresenter.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootPresenter.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import dev.zacsweers.metro.Inject import im.vector.app.features.analytics.plan.SuperProperties +import io.element.android.features.announcement.api.AnnouncementState import io.element.android.features.rageshake.api.crash.CrashDetectionState import io.element.android.features.rageshake.api.detection.RageshakeDetectionState import io.element.android.libraries.architecture.Presenter @@ -24,6 +25,7 @@ import io.element.android.services.apperror.api.AppErrorStateService class RootPresenter( private val crashDetectionPresenter: Presenter, private val rageshakeDetectionPresenter: Presenter, + private val announcementPresenter: Presenter, private val appErrorStateService: AppErrorStateService, private val analyticsService: AnalyticsService, private val sdkMetadata: SdkMetadata, @@ -32,6 +34,7 @@ class RootPresenter( override fun present(): RootState { val rageshakeDetectionState = rageshakeDetectionPresenter.present() val crashDetectionState = crashDetectionPresenter.present() + val announcementState = announcementPresenter.present() val appErrorState by appErrorStateService.appErrorStateFlow.collectAsState() LaunchedEffect(Unit) { @@ -48,6 +51,7 @@ class RootPresenter( rageshakeDetectionState = rageshakeDetectionState, crashDetectionState = crashDetectionState, errorState = appErrorState, + announcementState = announcementState, ) } } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootState.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootState.kt index 3ea7362efa..5ab995246f 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/root/RootState.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootState.kt @@ -8,6 +8,7 @@ package io.element.android.appnav.root import androidx.compose.runtime.Immutable +import io.element.android.features.announcement.api.AnnouncementState import io.element.android.features.rageshake.api.crash.CrashDetectionState import io.element.android.features.rageshake.api.detection.RageshakeDetectionState import io.element.android.services.apperror.api.AppErrorState @@ -17,4 +18,5 @@ data class RootState( val rageshakeDetectionState: RageshakeDetectionState, val crashDetectionState: CrashDetectionState, val errorState: AppErrorState, + val announcementState: AnnouncementState, ) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootStateProvider.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootStateProvider.kt index 4d84e06070..896e62d820 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/root/RootStateProvider.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootStateProvider.kt @@ -8,6 +8,7 @@ package io.element.android.appnav.root import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.announcement.api.anAnnouncementState import io.element.android.features.rageshake.api.crash.aCrashDetectionState import io.element.android.features.rageshake.api.detection.aRageshakeDetectionState import io.element.android.services.apperror.api.AppErrorState @@ -33,5 +34,6 @@ open class RootStateProvider : PreviewParameterProvider { fun aRootState() = RootState( rageshakeDetectionState = aRageshakeDetectionState(), crashDetectionState = aCrashDetectionState(), + announcementState = anAnnouncementState(), errorState = AppErrorState.NoError, ) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootView.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootView.kt index bd7db5e9c2..275ccaae84 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/root/RootView.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootView.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.features.announcement.api.AnnouncementState import io.element.android.features.rageshake.api.crash.CrashDetectionEvents import io.element.android.features.rageshake.api.crash.CrashDetectionView import io.element.android.features.rageshake.api.detection.RageshakeDetectionEvents @@ -27,6 +28,7 @@ import io.element.android.services.apperror.impl.AppErrorView fun RootView( state: RootState, onOpenBugReport: () -> Unit, + announcementRenderer: @Composable (AnnouncementState, Modifier) -> Unit, modifier: Modifier = Modifier, children: @Composable BoxScope.() -> Unit, ) { @@ -43,6 +45,11 @@ fun RootView( onOpenBugReport.invoke() } + announcementRenderer( + state.announcementState, + Modifier, + ) + RageshakeDetectionView( state = state.rageshakeDetectionState, onOpenBugReport = ::onOpenBugReport, @@ -63,6 +70,7 @@ internal fun RootViewPreview(@PreviewParameter(RootStateProvider::class) rootSta RootView( state = rootState, onOpenBugReport = {}, + announcementRenderer = { _, _ -> }, ) { Text("Children") } diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt index 2a343a1592..9d0d1e9572 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt @@ -12,6 +12,7 @@ import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.appnav.root.RootPresenter +import io.element.android.features.announcement.api.anAnnouncementState import io.element.android.features.rageshake.api.crash.aCrashDetectionState import io.element.android.features.rageshake.api.detection.aRageshakeDetectionState import io.element.android.libraries.matrix.test.FakeSdkMetadata @@ -71,6 +72,7 @@ class RootPresenterTest { return RootPresenter( crashDetectionPresenter = { aCrashDetectionState() }, rageshakeDetectionPresenter = { aRageshakeDetectionState() }, + announcementPresenter = { anAnnouncementState() }, appErrorStateService = appErrorService, analyticsService = FakeAnalyticsService(), sdkMetadata = FakeSdkMetadata("sha") diff --git a/features/announcement/api/build.gradle.kts b/features/announcement/api/build.gradle.kts new file mode 100644 index 0000000000..0fa87f039e --- /dev/null +++ b/features/announcement/api/build.gradle.kts @@ -0,0 +1,13 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.announcement.api" +} diff --git a/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/AnnouncementService.kt b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/AnnouncementService.kt new file mode 100644 index 0000000000..abd50aef3b --- /dev/null +++ b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/AnnouncementService.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2025 New Vector 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.announcement.api + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +interface AnnouncementService { + suspend fun onEnteringSpaceTab() + + @Composable + fun Render( + state: AnnouncementState, + modifier: Modifier, + ) +} diff --git a/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/AnnouncementState.kt b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/AnnouncementState.kt new file mode 100644 index 0000000000..eccdbd237c --- /dev/null +++ b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/AnnouncementState.kt @@ -0,0 +1,18 @@ +/* + * Copyright 2025 New Vector 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.announcement.api + +data class AnnouncementState( + val showSpaceAnnouncement: Boolean, +) + +fun anAnnouncementState( + showSpaceAnnouncement: Boolean = false, +) = AnnouncementState( + showSpaceAnnouncement = showSpaceAnnouncement, +) diff --git a/features/announcement/impl/build.gradle.kts b/features/announcement/impl/build.gradle.kts new file mode 100644 index 0000000000..0ef6d09061 --- /dev/null +++ b/features/announcement/impl/build.gradle.kts @@ -0,0 +1,36 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.announcement.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.preferences.api) + implementation(projects.libraries.uiStrings) + api(projects.features.announcement.api) + implementation(libs.androidx.datastore.preferences) + + testCommonDependencies(libs) +} diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenter.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenter.kt new file mode 100644 index 0000000000..435d69d1f5 --- /dev/null +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenter.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2025 New Vector 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.announcement.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import dev.zacsweers.metro.Inject +import io.element.android.features.announcement.api.AnnouncementState +import io.element.android.features.announcement.impl.store.AnnouncementStore +import io.element.android.libraries.architecture.Presenter +import kotlinx.coroutines.flow.map + +@Inject +class AnnouncementPresenter( + private val announcementStore: AnnouncementStore, +) : Presenter { + @Composable + override fun present(): AnnouncementState { + val showSpaceAnnouncement by remember { + announcementStore.spaceAnnouncementFlow().map { + it == AnnouncementStore.SpaceAnnouncement.Show + } + }.collectAsState(false) + return AnnouncementState( + showSpaceAnnouncement = showSpaceAnnouncement, + ) + } +} 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 new file mode 100644 index 0000000000..fb82246a3c --- /dev/null +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2025 New Vector 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.announcement.impl + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import io.element.android.features.announcement.api.AnnouncementService +import io.element.android.features.announcement.api.AnnouncementState +import io.element.android.features.announcement.impl.spaces.SpaceAnnouncementPresenter +import io.element.android.features.announcement.impl.spaces.SpaceAnnouncementView +import io.element.android.features.announcement.impl.store.AnnouncementStore +import kotlinx.coroutines.flow.first + +@ContributesBinding(AppScope::class) +@Inject +class DefaultAnnouncementService( + private val announcementStore: AnnouncementStore, + private val spaceAnnouncementPresenter: SpaceAnnouncementPresenter, +) : AnnouncementService { + override suspend fun onEnteringSpaceTab() { + val currentValue = announcementStore.spaceAnnouncementFlow().first() + if (currentValue == AnnouncementStore.SpaceAnnouncement.NeverShown) { + announcementStore.setSpaceAnnouncementValue(AnnouncementStore.SpaceAnnouncement.Show) + } + } + + @Composable + override fun Render(state: AnnouncementState, modifier: Modifier) { + Box(modifier = modifier.fillMaxSize()) { + AnimatedVisibility( + visible = state.showSpaceAnnouncement, + enter = fadeIn(), + exit = fadeOut(), + ) { + val spaceAnnouncementState = spaceAnnouncementPresenter.present() + SpaceAnnouncementView( + state = spaceAnnouncementState, + ) + } + } + } +} diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/di/AnnouncementModule.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/di/AnnouncementModule.kt new file mode 100644 index 0000000000..64998dfe1a --- /dev/null +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/di/AnnouncementModule.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2025 New Vector 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.announcement.impl.di + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo +import io.element.android.features.announcement.api.AnnouncementState +import io.element.android.features.announcement.impl.AnnouncementPresenter +import io.element.android.libraries.architecture.Presenter + +@ContributesTo(AppScope::class) +@BindingContainer +interface AnnouncementModule { + @Binds + fun bindAnnouncementPresenter(presenter: AnnouncementPresenter): Presenter +} diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementEvents.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementEvents.kt new file mode 100644 index 0000000000..9741608b1e --- /dev/null +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementEvents.kt @@ -0,0 +1,12 @@ +/* + * Copyright 2025 New Vector 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.announcement.impl.spaces + +sealed interface SpaceAnnouncementEvents { + data object Continue : SpaceAnnouncementEvents +} diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementNode.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementNode.kt new file mode 100644 index 0000000000..ba8f74d803 --- /dev/null +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementNode.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2025 New Vector 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.announcement.impl.spaces + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class SpaceAnnouncementNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: SpaceAnnouncementPresenter, +) : Node(buildContext, plugins = plugins) { + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + SpaceAnnouncementView( + state = state, + modifier = modifier, + ) + } +} diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenter.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenter.kt new file mode 100644 index 0000000000..dbe619a867 --- /dev/null +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenter.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2025 New Vector 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.announcement.impl.spaces + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import dev.zacsweers.metro.Inject +import io.element.android.features.announcement.impl.store.AnnouncementStore +import io.element.android.features.announcement.impl.store.AnnouncementStore.SpaceAnnouncement +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.meta.BuildMeta +import kotlinx.coroutines.launch + +@Inject +class SpaceAnnouncementPresenter( + private val buildMeta: BuildMeta, + private val announcementStore: AnnouncementStore, +) : Presenter { + @Composable + override fun present(): SpaceAnnouncementState { + val localCoroutineScope = rememberCoroutineScope() + + fun handleEvents(event: SpaceAnnouncementEvents) { + when (event) { + SpaceAnnouncementEvents.Continue -> localCoroutineScope.launch { + announcementStore.setSpaceAnnouncementValue(SpaceAnnouncement.Shown) + } + } + } + + return SpaceAnnouncementState( + applicationName = buildMeta.applicationName, + desktopApplicationName = buildMeta.desktopApplicationName, + eventSink = ::handleEvents + ) + } +} diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementState.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementState.kt new file mode 100644 index 0000000000..f02519a405 --- /dev/null +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementState.kt @@ -0,0 +1,14 @@ +/* + * Copyright 2025 New Vector 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.announcement.impl.spaces + +data class SpaceAnnouncementState( + val applicationName: String, + val desktopApplicationName: String, + val eventSink: (SpaceAnnouncementEvents) -> Unit +) diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementStateProvider.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementStateProvider.kt new file mode 100644 index 0000000000..0eb2e8ff48 --- /dev/null +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementStateProvider.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2025 New Vector 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.announcement.impl.spaces + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class SpaceAnnouncementStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aSpaceAnnouncementState(), + ) +} + +fun aSpaceAnnouncementState( + applicationName: String = "Element", + desktopApplicationName: String = "Element", + eventSink: (SpaceAnnouncementEvents) -> Unit = {}, +) = SpaceAnnouncementState( + applicationName = applicationName, + desktopApplicationName = desktopApplicationName, + eventSink = eventSink, +) diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementView.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementView.kt new file mode 100644 index 0000000000..58799c05bd --- /dev/null +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementView.kt @@ -0,0 +1,165 @@ +/* + * Copyright 2025 New Vector 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.announcement.impl.spaces + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +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.announcement.impl.R +import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem +import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism +import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage +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.Text +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.persistentListOf + +/** + * Ref: https://www.figma.com/design/kcnHxunG1LDWXsJhaNuiHz/ER-145--Spaces-on-Element-X?node-id=4593-40181 + */ +@Composable +fun SpaceAnnouncementView( + state: SpaceAnnouncementState, + modifier: Modifier = Modifier, +) { + val eventSink = state.eventSink + + fun onContinue() { + eventSink(SpaceAnnouncementEvents.Continue) + } + + BackHandler { + state.eventSink(SpaceAnnouncementEvents.Continue) + } + HeaderFooterPage( + modifier = modifier, + isScrollable = true, + contentPadding = PaddingValues(top = 24.dp, start = 16.dp, end = 16.dp, bottom = 24.dp), + header = { + SpaceAnnouncementHeader(state = state) + }, + content = { + SpaceAnnouncementContent( + state = state, + modifier = Modifier.padding(horizontal = 8.dp), + ) + }, + footer = { + SpaceAnnouncementFooter( + onContinue = ::onContinue, + ) + } + ) +} + +@Composable +private fun SpaceAnnouncementHeader( + state: SpaceAnnouncementState, + modifier: Modifier = Modifier, +) { + IconTitleSubtitleMolecule( + modifier = modifier.padding(top = 16.dp, bottom = 16.dp), + title = stringResource(id = R.string.screen_space_announcement_title), + showBetaLabel = true, + subTitle = stringResource( + id = R.string.screen_space_announcement_subtitle, + state.applicationName + ), + iconStyle = BigIcon.Style.Default( + vectorIcon = CompoundIcons.WorkspaceSolid(), + usePrimaryTint = true, + ), + ) +} + +@Composable +private fun SpaceAnnouncementContent( + state: SpaceAnnouncementState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxSize(), + ) { + InfoListOrganism( + modifier = Modifier.fillMaxWidth(), + items = persistentListOf( + InfoListItem( + message = stringResource(id = R.string.screen_space_announcement_item1, state.desktopApplicationName), + iconVector = CompoundIcons.VisibilityOn(), + ), + InfoListItem( + message = stringResource(id = R.string.screen_space_announcement_item2), + iconVector = CompoundIcons.Email(), + ), + InfoListItem( + message = stringResource(id = R.string.screen_space_announcement_item3), + iconVector = CompoundIcons.Search(), + ), + InfoListItem( + message = stringResource(id = R.string.screen_space_announcement_item4), + iconVector = CompoundIcons.Leave(), + ), + InfoListItem( + message = stringResource(id = R.string.screen_space_announcement_item5), + iconVector = CompoundIcons.Explore(), + ), + ), + textStyle = ElementTheme.typography.fontBodyLgMedium, + iconTint = ElementTheme.colors.iconSecondary, + iconSize = 24.dp + ) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + text = stringResource(id = R.string.screen_space_announcement_notice), + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun SpaceAnnouncementFooter( + onContinue: () -> Unit, +) { + ButtonColumnMolecule( + modifier = Modifier.padding(bottom = 8.dp) + ) { + Button( + text = stringResource(id = CommonStrings.action_continue), + onClick = onContinue, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@PreviewsDayNight +@Composable +internal fun SpaceAnnouncementViewPreview(@PreviewParameter(SpaceAnnouncementStateProvider::class) state: SpaceAnnouncementState) = ElementPreview { + SpaceAnnouncementView( + state = state, + ) +} diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/AnnouncementStore.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/AnnouncementStore.kt new file mode 100644 index 0000000000..dd12120d23 --- /dev/null +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/AnnouncementStore.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2025 New Vector 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.announcement.impl.store + +import kotlinx.coroutines.flow.Flow + +interface AnnouncementStore { + suspend fun setSpaceAnnouncementValue(value: SpaceAnnouncement) + fun spaceAnnouncementFlow(): Flow + + suspend fun reset() + + enum class SpaceAnnouncement { + NeverShown, + Show, + Shown, + } +} 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 new file mode 100644 index 0000000000..922a4aaaa7 --- /dev/null +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2025 New Vector 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.announcement.impl.store + +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val spaceAnnouncementKey = intPreferencesKey("spaceAnnouncement") + +@ContributesBinding(AppScope::class) +@Inject +class DefaultAnnouncementStore( + preferenceDataStoreFactory: PreferenceDataStoreFactory, +) : AnnouncementStore { + private val store = preferenceDataStoreFactory.create("elementx_announcement") + + override suspend fun setSpaceAnnouncementValue(value: AnnouncementStore.SpaceAnnouncement) { + store.edit { + it[spaceAnnouncementKey] = value.ordinal + } + } + + override fun spaceAnnouncementFlow(): Flow { + return store.data.map { prefs -> + val ordinal = prefs[spaceAnnouncementKey] ?: AnnouncementStore.SpaceAnnouncement.NeverShown.ordinal + AnnouncementStore.SpaceAnnouncement.entries.getOrElse(ordinal) { AnnouncementStore.SpaceAnnouncement.NeverShown } + } + } + + override suspend fun reset() { + store.edit { it.clear() } + } +} diff --git a/features/announcement/impl/src/main/res/values/localazy.xml b/features/announcement/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..b995021dfd --- /dev/null +++ b/features/announcement/impl/src/main/res/values/localazy.xml @@ -0,0 +1,11 @@ + + + "View spaces you’ve created or joined on %1$s desktop" + "Accept or decline invites to spaces" + "Discover any rooms you can join in your spaces" + "Leave any spaces you’ve joined" + "Join public spaces" + "More features will be added in the future, such as creating or managing spaces on mobile." + "Welcome to the beta version of Spaces on %1$s mobile! With this first version you can:" + "Introducing Spaces" + diff --git a/features/announcement/impl/src/test/kotlin/io/element/android/features/rageshake/impl/AnnouncementPresenterTest.kt b/features/announcement/impl/src/test/kotlin/io/element/android/features/rageshake/impl/AnnouncementPresenterTest.kt new file mode 100644 index 0000000000..5bfa74bc27 --- /dev/null +++ b/features/announcement/impl/src/test/kotlin/io/element/android/features/rageshake/impl/AnnouncementPresenterTest.kt @@ -0,0 +1,18 @@ +/* + * Copyright 2025 New Vector 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.rageshake.impl + +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class AnnouncementPresenterTest { + @Test + fun `present - initial test`() = runTest { + // TODO + } +} diff --git a/features/announcement/test/build.gradle.kts b/features/announcement/test/build.gradle.kts new file mode 100644 index 0000000000..9387dc0caf --- /dev/null +++ b/features/announcement/test/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.announcement.test" +} + +dependencies { + implementation(projects.features.announcement.api) + implementation(libs.coroutines.core) + implementation(projects.tests.testutils) +} diff --git a/features/announcement/test/src/main/kotlin/io/element/android/features/rageshake/test/logs/FakeAnnouncementService.kt b/features/announcement/test/src/main/kotlin/io/element/android/features/rageshake/test/logs/FakeAnnouncementService.kt new file mode 100644 index 0000000000..3d3bbfcaba --- /dev/null +++ b/features/announcement/test/src/main/kotlin/io/element/android/features/rageshake/test/logs/FakeAnnouncementService.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2024 New Vector 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.rageshake.test.logs + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import io.element.android.features.announcement.api.AnnouncementService +import io.element.android.features.announcement.api.AnnouncementState +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeAnnouncementService( + val onEnteringSpaceTabResult: () -> Unit = { lambdaError() }, + val renderResult: (AnnouncementState, Modifier) -> Unit = { _, _ -> lambdaError() }, +) : AnnouncementService { + override suspend fun onEnteringSpaceTab() { + onEnteringSpaceTabResult() + } + + @Composable + override fun Render(state: AnnouncementState, modifier: Modifier) { + renderResult(state, modifier) + } +} diff --git a/features/home/impl/build.gradle.kts b/features/home/impl/build.gradle.kts index 2bf09c9398..9a29532ef0 100644 --- a/features/home/impl/build.gradle.kts +++ b/features/home/impl/build.gradle.kts @@ -45,6 +45,7 @@ dependencies { implementation(projects.libraries.permissions.noop) implementation(projects.libraries.preferences.api) implementation(projects.libraries.push.api) + implementation(projects.features.announcement.api) implementation(projects.features.invite.api) implementation(projects.features.networkmonitor.api) implementation(projects.features.logout.api) @@ -60,6 +61,7 @@ dependencies { api(projects.features.home.api) testCommonDependencies(libs, true) + testImplementation(projects.features.announcement.test) testImplementation(projects.features.invite.test) testImplementation(projects.features.logout.test) testImplementation(projects.features.networkmonitor.test) 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 653a7134f3..d5f3e68898 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 @@ -18,6 +18,7 @@ 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.AnnouncementService import io.element.android.features.home.impl.roomlist.RoomListState import io.element.android.features.home.impl.spaces.HomeSpacesState import io.element.android.features.logout.api.direct.DirectLogoutState @@ -47,6 +48,7 @@ class HomePresenter( private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, private val featureFlagService: FeatureFlagService, private val sessionStore: SessionStore, + private val announcementService: AnnouncementService, ) : Presenter { private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder() @@ -84,7 +86,10 @@ class HomePresenter( fun handleEvents(event: HomeEvents) { when (event) { - is HomeEvents.SelectHomeNavigationBarItem -> { + is HomeEvents.SelectHomeNavigationBarItem -> coroutineState.launch { + if (event.item == HomeNavigationBarItem.Spaces) { + announcementService.onEnteringSpaceTab() + } currentHomeNavigationBarItemOrdinal = event.item.ordinal } is HomeEvents.SwitchToAccount -> coroutineState.launch { 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 8e5b35de99..aa5a612760 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 @@ -11,11 +11,13 @@ import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +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.logout.api.direct.aDirectLogoutState 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 @@ -37,6 +39,7 @@ import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.tests.testutils.MutablePresenter import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.test import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest @@ -165,10 +168,14 @@ class HomePresenterTest { @Test fun `present - NavigationBar change`() = runTest { + val onEnteringSpaceTabResult = lambdaRecorder { } val presenter = createHomePresenter( sessionStore = InMemorySessionStore( updateUserProfileResult = { _, _, _ -> }, ), + announcementService = FakeAnnouncementService( + onEnteringSpaceTabResult = onEnteringSpaceTabResult, + ) ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -178,6 +185,7 @@ class HomePresenterTest { initialState.eventSink(HomeEvents.SelectHomeNavigationBarItem(HomeNavigationBarItem.Spaces)) val finalState = awaitItem() assertThat(finalState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Spaces) + onEnteringSpaceTabResult.assertions().isCalledOnce() } } @@ -192,6 +200,9 @@ class HomePresenterTest { initialState = mapOf(FeatureFlags.Space.key to true), ), homeSpacesPresenter = homeSpacesPresenter, + announcementService = FakeAnnouncementService( + onEnteringSpaceTabResult = {}, + ) ) presenter.test { skipItems(1) @@ -222,6 +233,7 @@ internal fun createHomePresenter( featureFlagService: FeatureFlagService = FakeFeatureFlagService(), homeSpacesPresenter: Presenter = Presenter { aHomeSpacesState() }, sessionStore: SessionStore = InMemorySessionStore(), + announcementService: AnnouncementService = FakeAnnouncementService(), ) = HomePresenter( client = client, syncService = syncService, @@ -233,4 +245,5 @@ internal fun createHomePresenter( rageshakeFeatureAvailability = rageshakeFeatureAvailability, featureFlagService = featureFlagService, sessionStore = sessionStore, + announcementService = announcementService, ) diff --git a/tools/localazy/config.json b/tools/localazy/config.json index 2afebbef4d..73347a5896 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -22,6 +22,12 @@ "settings_rageshake.*" ] }, + { + "name" : ":features:announcement:impl", + "includeRegex" : [ + "screen\\.space_announcement\\..*" + ] + }, { "name" : ":features:logout:impl", "includeRegex" : [