Announcement for Spaces

This commit is contained in:
Benoit Marty 2025-10-02 23:13:43 +02:00
parent 9f71bc4575
commit 2907f762f6
30 changed files with 690 additions and 1 deletions

View file

@ -44,6 +44,7 @@ dependencies {
implementation(libs.coil)
implementation(projects.features.announcement.api)
implementation(projects.features.ftue.api)
implementation(projects.features.share.api)

View file

@ -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<RootFlowNode.NavTarget>(
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<NavTarget>(
transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) },

View file

@ -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<CrashDetectionState>,
private val rageshakeDetectionPresenter: Presenter<RageshakeDetectionState>,
private val announcementPresenter: Presenter<AnnouncementState>,
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,
)
}
}

View file

@ -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,
)

View file

@ -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<RootState> {
fun aRootState() = RootState(
rageshakeDetectionState = aRageshakeDetectionState(),
crashDetectionState = aCrashDetectionState(),
announcementState = anAnnouncementState(),
errorState = AppErrorState.NoError,
)

View file

@ -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")
}

View file

@ -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")

View file

@ -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"
}

View file

@ -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,
)
}

View file

@ -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,
)

View file

@ -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)
}

View file

@ -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<AnnouncementState> {
@Composable
override fun present(): AnnouncementState {
val showSpaceAnnouncement by remember {
announcementStore.spaceAnnouncementFlow().map {
it == AnnouncementStore.SpaceAnnouncement.Show
}
}.collectAsState(false)
return AnnouncementState(
showSpaceAnnouncement = showSpaceAnnouncement,
)
}
}

View file

@ -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,
)
}
}
}
}

View file

@ -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<AnnouncementState>
}

View file

@ -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
}

View file

@ -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<Plugin>,
private val presenter: SpaceAnnouncementPresenter,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
SpaceAnnouncementView(
state = state,
modifier = modifier,
)
}
}

View file

@ -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<SpaceAnnouncementState> {
@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
)
}
}

View file

@ -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
)

View file

@ -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<SpaceAnnouncementState> {
override val values: Sequence<SpaceAnnouncementState>
get() = sequenceOf(
aSpaceAnnouncementState(),
)
}
fun aSpaceAnnouncementState(
applicationName: String = "Element",
desktopApplicationName: String = "Element",
eventSink: (SpaceAnnouncementEvents) -> Unit = {},
) = SpaceAnnouncementState(
applicationName = applicationName,
desktopApplicationName = desktopApplicationName,
eventSink = eventSink,
)

View file

@ -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,
)
}

View file

@ -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<SpaceAnnouncement>
suspend fun reset()
enum class SpaceAnnouncement {
NeverShown,
Show,
Shown,
}
}

View file

@ -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<AnnouncementStore.SpaceAnnouncement> {
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() }
}
}

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_space_announcement_item1">"View spaces youve created or joined on %1$s desktop"</string>
<string name="screen_space_announcement_item2">"Accept or decline invites to spaces"</string>
<string name="screen_space_announcement_item3">"Discover any rooms you can join in your spaces"</string>
<string name="screen_space_announcement_item4">"Leave any spaces youve joined"</string>
<string name="screen_space_announcement_item5">"Join public spaces"</string>
<string name="screen_space_announcement_notice">"More features will be added in the future, such as creating or managing spaces on mobile."</string>
<string name="screen_space_announcement_subtitle">"Welcome to the beta version of Spaces on %1$s mobile! With this first version you can:"</string>
<string name="screen_space_announcement_title">"Introducing Spaces"</string>
</resources>

View file

@ -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
}
}

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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)

View file

@ -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<HomeState> {
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 {

View file

@ -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<Unit> { }
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<HomeSpacesState> = 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,
)

View file

@ -22,6 +22,12 @@
"settings_rageshake.*"
]
},
{
"name" : ":features:announcement:impl",
"includeRegex" : [
"screen\\.space_announcement\\..*"
]
},
{
"name" : ":features:logout:impl",
"includeRegex" : [