Merge pull request #7865 from element-hq/feature/bma/multiAccountBottomSheetAnnouncement

Announce multi-account support
This commit is contained in:
Benoit Marty
2026-10-06 12:09:38 +02:00
committed by GitHub
28 changed files with 405 additions and 9 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ PRs must meet these rules.
- Create previews for **all main states** of a Composable. - Create previews for **all main states** of a Composable.
- Use `@PreviewsDayNight` for consistency. - Use `@PreviewsDayNight` for consistency.
- Use `PreviewParameterProvider` (e.g., `FooStatePreviewParam`) to provide states. - Use `PreviewParameterProvider` (e.g., `FooStatePreviewParam`) to provide states. Always add new states to existing providers at the end of the sequence.
- Wrap previews in `ElementPreview { ... }`. - Wrap previews in `ElementPreview { ... }`.
- When writing tests, never try to record the screenshots, the CI will do it. - When writing tests, never try to record the screenshots, the CI will do it.
@@ -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,
@@ -13,6 +13,7 @@ package io.element.android.appnav
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import coil3.SingletonImageLoader import coil3.SingletonImageLoader
import coil3.annotation.DelicateCoilApi import coil3.annotation.DelicateCoilApi
import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.lifecycle.subscribe
@@ -24,6 +25,8 @@ import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.features.login.api.LoginEntryPoint import io.element.android.features.login.api.LoginEntryPoint
import io.element.android.features.login.api.LoginParams import io.element.android.features.login.api.LoginParams
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
@@ -33,8 +36,11 @@ import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.utils.ForceOrientationInMobileDevices import io.element.android.libraries.designsystem.utils.ForceOrientationInMobileDevices
import io.element.android.libraries.designsystem.utils.ScreenOrientation import io.element.android.libraries.designsystem.utils.ScreenOrientation
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class) @ContributesNode(AppScope::class)
@@ -45,6 +51,8 @@ class NotLoggedInFlowNode(
private val loginEntryPoint: LoginEntryPoint, private val loginEntryPoint: LoginEntryPoint,
private val imageLoaderHolder: ImageLoaderHolder, private val imageLoaderHolder: ImageLoaderHolder,
private val analyticsColdStartWatcher: AnalyticsColdStartWatcher, private val analyticsColdStartWatcher: AnalyticsColdStartWatcher,
private val featureFlagService: FeatureFlagService,
private val announcementService: AnnouncementService,
) : BaseFlowNode<NotLoggedInFlowNode.NavTarget>( ) : BaseFlowNode<NotLoggedInFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Root, initialElement = NavTarget.Root,
@@ -68,6 +76,13 @@ class NotLoggedInFlowNode(
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
analyticsColdStartWatcher.whenLoggingIn() analyticsColdStartWatcher.whenLoggingIn()
lifecycleScope.launch {
// Users who enter the login flow while the feature is available, either for the first time or to add
// another account, do not need to be informed about the multi-account feature.
if (featureFlagService.isFeatureEnabled(FeatureFlags.MultiAccount)) {
announcementService.onAnnouncementDismissed(Announcement.MultiAccount)
}
}
lifecycle.subscribe( lifecycle.subscribe(
onResume = { onResume = {
SingletonImageLoader.setUnsafe(imageLoaderHolder.get()) SingletonImageLoader.setUnsafe(imageLoaderHolder.get())
@@ -141,6 +141,8 @@ class RootFlowNodeTest : RobolectricTest() {
loginEntryPoint = loginEntryPoint, loginEntryPoint = loginEntryPoint,
imageLoaderHolder = FakeImageLoaderHolder(), imageLoaderHolder = FakeImageLoaderHolder(),
analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(), analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(),
featureFlagService = FakeFeatureFlagService(),
announcementService = FakeAnnouncementService(),
) )
} }
) )
@@ -17,4 +17,6 @@ sealed interface Announcement {
} }
data object NewNotificationSound : Announcement data object NewNotificationSound : Announcement
data object MultiAccount : Announcement
} }
@@ -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,22 @@ 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)
} }
// The multi-account announcement is pending until it is dismissed. Users who were already
// logged in when the feature became available still have the default NeverShown status.
if (multiAccountStatus != AnnouncementStatus.Shown) {
add(Announcement.MultiAccount)
}
} }
} }
} }
private suspend fun showFullscreenAnnouncement(announcement: Announcement.Fullscreen) { private suspend fun showAnnouncementIfNeverShown(announcement: Announcement) {
val currentValue = announcementStore.announcementStatusFlow(announcement).first() val currentValue = announcementStore.announcementStatusFlow(announcement).first()
if (currentValue == AnnouncementStatus.NeverShown) { if (currentValue == AnnouncementStatus.NeverShown) {
announcementStore.setAnnouncementStatus(announcement, AnnouncementStatus.Show) announcementStore.setAnnouncementStatus(announcement, AnnouncementStatus.Show)
@@ -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,15 @@ 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 NeverShown by default, which means pending. It is set to Shown when the user enters
// the login flow while the feature is enabled (see NotLoggedInFlowNode), or dismisses the announcement.
val defaultStatus = when (announcement) { 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.NeverShown
} }
return store.data.map { prefs -> return store.data.map { prefs ->
val ordinal = prefs[key] ?: defaultStatus.ordinal val ordinal = prefs[key] ?: defaultStatus.ordinal
@@ -56,4 +61,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
} }
@@ -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()
@@ -58,10 +74,16 @@ class DefaultAnnouncementServiceTest {
announcementStore = announcementStore, announcementStore = announcementStore,
) )
sut.announcementsToShowFlow().test { sut.announcementsToShowFlow().test {
assertThat(awaitItem()).isEmpty() // MultiAccount is pending by default
assertThat(awaitItem()).containsExactly(Announcement.MultiAccount)
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show) announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show)
assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound) assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount)
announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Show)
// Still pending
assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount)
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Shown) announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Shown)
assertThat(awaitItem()).containsExactly(Announcement.MultiAccount)
announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Shown)
assertThat(awaitItem()).isEmpty() assertThat(awaitItem()).isEmpty()
} }
} }
@@ -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
} }
} }
@@ -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
} }
@@ -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,
@@ -19,18 +19,23 @@ 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
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Inject @Inject
@@ -43,6 +48,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 +66,30 @@ 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)
LaunchedEffect(Unit) {
// The user already uses several accounts, so the feature does not need to be announced anymore,
// even if they later remove accounts.
combine(
announcementService.announcementsToShowFlow(),
sessionStore.sessionsFlow(),
) { announcements, sessions ->
announcements.contains(Announcement.MultiAccount) && sessions.size > 1
}
.filter { it }
.collect {
announcementService.onAnnouncementDismissed(Announcement.MultiAccount)
}
}
val roomListState = roomListPresenter.present() val 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 +113,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 +129,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
@@ -48,6 +48,8 @@ open class HomeStatePreviewParam : PreviewParameterProvider<HomeState> {
homeSpacesState = aHomeSpacesState( homeSpacesState = aHomeSpacesState(
spaceRooms = emptyList(), spaceRooms = emptyList(),
), ),
) + aHomeState(
showMultiAccountAnnouncement = true,
) )
} }
@@ -61,6 +63,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 +71,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 = {},
@@ -0,0 +1,128 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.home.impl.R
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.annotatedTextWithBold
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
/**
* Bottom sheet announcing that several accounts can now be added.
* Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=195-43073
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun MultiAccountAnnouncementBottomSheet(
onDismiss: () -> Unit,
onAddAccountClick: () -> Unit,
) {
val sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
)
val coroutineScope = rememberCoroutineScope()
ModalBottomSheet(
sheetState = sheetState,
onDismissRequest = onDismiss,
scrollable = false,
) {
MultiAccountAnnouncementContent(
onAddAccountClick = {
coroutineScope.launch {
sheetState.hide()
onDismiss()
onAddAccountClick()
}
},
)
}
}
@Composable
private fun MultiAccountAnnouncementContent(
onAddAccountClick: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(all = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
BigIcon(
style = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()),
)
Spacer(Modifier.height(16.dp))
Text(
modifier = Modifier
.padding(horizontal = 8.dp)
.semantics { heading() },
text = stringResource(R.string.screen_multi_account_announcement_title),
style = ElementTheme.typography.fontHeadingMdBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(8.dp))
val path = stringResource(CommonStrings.common_settings) + " > " + stringResource(CommonStrings.common_add_another_account)
Text(
modifier = Modifier.padding(horizontal = 8.dp),
text = annotatedTextWithBold(
text = stringResource(R.string.screen_multi_account_announcement_description, path),
boldText = path,
),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(40.dp))
Button(
text = stringResource(R.string.screen_multi_account_announcement_action),
leadingIcon = IconSource.Vector(CompoundIcons.Plus()),
modifier = Modifier.fillMaxWidth(),
onClick = onAddAccountClick,
)
Spacer(Modifier.height(16.dp))
}
}
@PreviewsDayNight
@Composable
internal fun MultiAccountAnnouncementBottomSheetPreview() = ElementPreview(fillMaxSize = true) {
MultiAccountAnnouncementBottomSheet(
onDismiss = {},
onAddAccountClick = {},
)
}
@@ -24,6 +24,9 @@
<string name="screen_invites_invited_you">"%1$s (%2$s) invited you"</string> <string name="screen_invites_invited_you">"%1$s (%2$s) invited you"</string>
<string name="screen_migration_message">"This is a one time process, thanks for waiting."</string> <string name="screen_migration_message">"This is a one time process, thanks for waiting."</string>
<string name="screen_migration_title">"Setting up your account."</string> <string name="screen_migration_title">"Setting up your account."</string>
<string name="screen_multi_account_announcement_action">"Add account"</string>
<string name="screen_multi_account_announcement_description">"Go to %1$s to sign into another account. Then you can easily switch between them."</string>
<string name="screen_multi_account_announcement_title">"You can now add multiple accounts"</string>
<string name="screen_roomlist_a11y_create_message">"Create a new conversation or room"</string> <string name="screen_roomlist_a11y_create_message">"Create a new conversation or room"</string>
<string name="screen_roomlist_clear_filters">"Clear filters"</string> <string name="screen_roomlist_clear_filters">"Clear filters"</string>
<string name="screen_roomlist_empty_message">"Get started by messaging someone."</string> <string name="screen_roomlist_empty_message">"Get started by messaging someone."</string>
@@ -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,
@@ -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,99 @@ 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 and is dismissed when there are several accounts`() = runTest {
val onAnnouncementDismissedResult = lambdaRecorder<Announcement, Unit> { }
val presenter = createHomePresenter(
sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(sessionId = "@alice:server.org"),
aSessionData(sessionId = "@bob:server.org"),
),
updateUserProfileResult = { _, _, _, _ -> },
),
announcementService = FakeAnnouncementService(
initialAnnouncementsToShowFlowValue = listOf(Announcement.MultiAccount),
onAnnouncementDismissedResult = onAnnouncementDismissedResult,
),
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.MultiAccount.key to true),
),
)
presenter.test {
assertThat(expectMostRecentItem().showMultiAccountAnnouncement).isFalse()
onAnnouncementDismissedResult.assertions().isCalledOnce()
.with(value(Announcement.MultiAccount))
}
}
@Test
fun `present - dismiss multi account announcement`() = runTest {
val onAnnouncementDismissedResult = lambdaRecorder<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 +255,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 +266,6 @@ internal fun createHomePresenter(
homeSpacesPresenter = homeSpacesPresenter, homeSpacesPresenter = homeSpacesPresenter,
rageshakeFeatureAvailability = rageshakeFeatureAvailability, rageshakeFeatureAvailability = rageshakeFeatureAvailability,
sessionStore = sessionStore, sessionStore = sessionStore,
announcementService = announcementService,
featureFlagService = featureFlagService,
) )
@@ -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,
@@ -110,6 +110,7 @@ If you proceed, some of your settings may change."</string>
<string name="screen_notification_settings_system_notifications_action_required_content_link">"system settings"</string> <string name="screen_notification_settings_system_notifications_action_required_content_link">"system settings"</string>
<string name="screen_notification_settings_system_notifications_turned_off">"System notifications turned off"</string> <string name="screen_notification_settings_system_notifications_turned_off">"System notifications turned off"</string>
<string name="screen_notification_settings_title">"Notifications"</string> <string name="screen_notification_settings_title">"Notifications"</string>
<string name="screen_settings_add_account">"Add account"</string>
<string name="screen_settings_user_status_away">"Away"</string> <string name="screen_settings_user_status_away">"Away"</string>
<string name="screen_settings_user_status_be_right_back">"Be right back"</string> <string name="screen_settings_user_status_be_right_back">"Be right back"</string>
<string name="screen_settings_user_status_custom">"Custom…"</string> <string name="screen_settings_user_status_custom">"Custom…"</string>
@@ -247,6 +247,7 @@
<string name="common_call_started">"Call started"</string> <string name="common_call_started">"Call started"</string>
<string name="common_call_you_declined">"You declined a call"</string> <string name="common_call_you_declined">"You declined a call"</string>
<string name="common_chat_backup">"Chat backup"</string> <string name="common_chat_backup">"Chat backup"</string>
<string name="common_chats">"Chats"</string>
<string name="common_code_block">"Code block"</string> <string name="common_code_block">"Code block"</string>
<string name="common_copied_to_clipboard">"Copied to clipboard"</string> <string name="common_copied_to_clipboard">"Copied to clipboard"</string>
<string name="common_copyright">"Copyright"</string> <string name="common_copyright">"Copyright"</string>
@@ -287,6 +288,7 @@ Reason: %1$s."</string>
<string name="common_file_saved">"File saved"</string> <string name="common_file_saved">"File saved"</string>
<string name="common_file_saved_on_disk_android">"File saved to Downloads"</string> <string name="common_file_saved_on_disk_android">"File saved to Downloads"</string>
<string name="common_forward_message">"Forward message"</string> <string name="common_forward_message">"Forward message"</string>
<string name="common_forward_to">"Forward to"</string>
<string name="common_frequently_used">"Frequently used"</string> <string name="common_frequently_used">"Frequently used"</string>
<string name="common_gallery">"Gallery"</string> <string name="common_gallery">"Gallery"</string>
<plurals name="common_gallery_reply_attachments"> <plurals name="common_gallery_reply_attachments">
@@ -299,6 +301,7 @@ Reason: %1$s."</string>
</plurals> </plurals>
<string name="common_gif">"GIF"</string> <string name="common_gif">"GIF"</string>
<string name="common_group_call_in_progress">"Group call in progress"</string> <string name="common_group_call_in_progress">"Group call in progress"</string>
<string name="common_header_rooms">"Rooms"</string>
<string name="common_image">"Image"</string> <string name="common_image">"Image"</string>
<string name="common_in_reply_to">"In reply to %1$s"</string> <string name="common_in_reply_to">"In reply to %1$s"</string>
<string name="common_install_apk_android">"Install APK"</string> <string name="common_install_apk_android">"Install APK"</string>
+1
View File
@@ -199,6 +199,7 @@
"screen\\.home\\..*", "screen\\.home\\..*",
"screen_roomlist_.*", "screen_roomlist_.*",
"screen\\.roomlist\\..*", "screen\\.roomlist\\..*",
"screen\\.multi_account_announcement\\..*",
"session_verification_banner_.*", "session_verification_banner_.*",
"confirm_recovery_key_banner_.*", "confirm_recovery_key_banner_.*",
"banner\\.set_up_recovery\\..*", "banner\\.set_up_recovery\\..*",