Merge pull request #7741 from element-hq/bma/settingsIterationStep1

Settings iteration: move user status and update multi-account section
This commit is contained in:
Benoit Marty
2026-09-21 15:54:11 +02:00
committed by GitHub
38 changed files with 386 additions and 117 deletions
@@ -13,4 +13,5 @@ import io.element.android.libraries.matrix.api.core.SessionId
sealed interface PreferencesRootEvent { sealed interface PreferencesRootEvent {
data object OnVersionInfoClick : PreferencesRootEvent data object OnVersionInfoClick : PreferencesRootEvent
data class SwitchToSession(val sessionId: SessionId) : PreferencesRootEvent data class SwitchToSession(val sessionId: SessionId) : PreferencesRootEvent
data object ToggleOtherAccountsExpanded : PreferencesRootEvent
} }
@@ -34,6 +34,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@@ -57,6 +58,7 @@ class PreferencesRootPresenter(
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val sessionStore: SessionStore, private val sessionStore: SessionStore,
private val appPreferencesStore: AppPreferencesStore,
private val sessionEnterpriseService: SessionEnterpriseService, private val sessionEnterpriseService: SessionEnterpriseService,
private val userStatusPresenter: Presenter<UserStatusState>, private val userStatusPresenter: Presenter<UserStatusState>,
) : Presenter<PreferencesRootState> { ) : Presenter<PreferencesRootState> {
@@ -71,6 +73,9 @@ class PreferencesRootPresenter(
val isMultiAccountEnabled by remember { val isMultiAccountEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MultiAccount) featureFlagService.isFeatureEnabledFlow(FeatureFlags.MultiAccount)
}.collectAsState(initial = false) }.collectAsState(initial = false)
val isOtherAccountsSectionExpanded by remember {
appPreferencesStore.isOtherAccountsExpandedFlow()
}.collectAsState(initial = true)
val showLinkNewDevice by remember { val showLinkNewDevice by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin) featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin)
}.collectAsState(initial = false) }.collectAsState(initial = false)
@@ -138,6 +143,9 @@ class PreferencesRootPresenter(
is PreferencesRootEvent.SwitchToSession -> coroutineScope.launch { is PreferencesRootEvent.SwitchToSession -> coroutineScope.launch {
sessionStore.setLatestSession(event.sessionId.value) sessionStore.setLatestSession(event.sessionId.value)
} }
PreferencesRootEvent.ToggleOtherAccountsExpanded -> coroutineScope.launch {
appPreferencesStore.setOtherAccountsExpanded(!isOtherAccountsSectionExpanded)
}
} }
} }
@@ -146,6 +154,7 @@ class PreferencesRootPresenter(
userStatusState = userStatusState, userStatusState = userStatusState,
version = remember { versionFormatter.get() }, version = remember { versionFormatter.get() },
isMultiAccountEnabled = isMultiAccountEnabled, isMultiAccountEnabled = isMultiAccountEnabled,
isOtherAccountsSectionExpanded = isOtherAccountsSectionExpanded,
otherSessions = otherSessions, otherSessions = otherSessions,
showSecureBackup = !canVerifyUserSession, showSecureBackup = !canVerifyUserSession,
showSecureBackupBadge = showSecureBackupIndicator, showSecureBackupBadge = showSecureBackupIndicator,
@@ -19,6 +19,7 @@ data class PreferencesRootState(
val userStatusState: UserStatusState?, val userStatusState: UserStatusState?,
val version: String, val version: String,
val isMultiAccountEnabled: Boolean, val isMultiAccountEnabled: Boolean,
val isOtherAccountsSectionExpanded: Boolean,
val otherSessions: ImmutableList<MatrixUser>, val otherSessions: ImmutableList<MatrixUser>,
val showSecureBackup: Boolean, val showSecureBackup: Boolean,
val showSecureBackupBadge: Boolean, val showSecureBackupBadge: Boolean,
@@ -47,6 +47,7 @@ open class PreferencesRootStatePreviewParam : PreviewParameterProvider<Preferenc
), ),
aPreferencesRootState( aPreferencesRootState(
isMultiAccountEnabled = true, isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = true,
otherSessions = aMatrixUserList().drop(1).take(3), otherSessions = aMatrixUserList().drop(1).take(3),
accountManagementUrl = "aUrl", accountManagementUrl = "aUrl",
showSecureBackup = true, showSecureBackup = true,
@@ -74,6 +75,7 @@ fun aPreferencesRootState(
myUser: MatrixUser = aMatrixUser(), myUser: MatrixUser = aMatrixUser(),
version: String = "Version 1.1 (1)", version: String = "Version 1.1 (1)",
isMultiAccountEnabled: Boolean = false, isMultiAccountEnabled: Boolean = false,
isOtherAccountsSectionExpanded: Boolean = false,
otherSessions: List<MatrixUser> = emptyList(), otherSessions: List<MatrixUser> = emptyList(),
showSecureBackup: Boolean = false, showSecureBackup: Boolean = false,
showSecureBackupBadge: Boolean = false, showSecureBackupBadge: Boolean = false,
@@ -93,6 +95,7 @@ fun aPreferencesRootState(
myUser = myUser, myUser = myUser,
version = version, version = version,
isMultiAccountEnabled = isMultiAccountEnabled, isMultiAccountEnabled = isMultiAccountEnabled,
isOtherAccountsSectionExpanded = isOtherAccountsSectionExpanded,
otherSessions = otherSessions.toImmutableList(), otherSessions = otherSessions.toImmutableList(),
showSecureBackup = showSecureBackup, showSecureBackup = showSecureBackup,
showSecureBackupBadge = showSecureBackupBadge, showSecureBackupBadge = showSecureBackupBadge,
@@ -9,17 +9,28 @@
package io.element.android.features.preferences.impl.root package io.element.android.features.preferences.impl.root
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -33,13 +44,16 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.components.async.AsyncActionIndicator import io.element.android.libraries.designsystem.components.async.AsyncActionIndicator
import io.element.android.libraries.designsystem.components.async.AsyncIndicator import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.components.avatar.AvatarRow
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferencePage import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle import io.element.android.libraries.designsystem.theme.components.ListItemStyle
@@ -51,7 +65,9 @@ import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.MatrixUserRow import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableList
@Composable @Composable
fun PreferencesRootView( fun PreferencesRootView(
@@ -91,19 +107,22 @@ fun PreferencesRootView(
}, },
matrixUser = state.myUser, matrixUser = state.myUser,
) )
if (state.userStatusState != null) {
UserStatusSection(
userStatusState = state.userStatusState,
emojiPickerRenderer = emojiPickerRenderer,
)
}
if (state.isMultiAccountEnabled) { if (state.isMultiAccountEnabled) {
MultiAccountSection( MultiAccountSection(
state = state, state = state,
onAddAccountClick = onAddAccountClick, onAddAccountClick = onAddAccountClick,
) )
} }
if (state.userStatusState != null) { HorizontalDivider(
UserStatusSection( thickness = 8.dp,
userStatusState = state.userStatusState, color = ElementTheme.colors.bgSubtleSecondary,
emojiPickerRenderer = emojiPickerRenderer, )
showTopDivider = !state.isMultiAccountEnabled,
)
}
// 'Account' section // 'Account' section
ManageAccountSection( ManageAccountSection(
state = state, state = state,
@@ -163,25 +182,21 @@ private fun BoxScope.UserStatusUpdateIndicator(updateStatusAction: AsyncAction<U
private fun ColumnScope.UserStatusSection( private fun ColumnScope.UserStatusSection(
userStatusState: UserStatusState, userStatusState: UserStatusState,
emojiPickerRenderer: EmojiPickerRenderer, emojiPickerRenderer: EmojiPickerRenderer,
showTopDivider: Boolean,
) { ) {
if (showTopDivider) { HorizontalDivider(
HorizontalDivider( thickness = 1.dp,
thickness = 8.dp, color = ElementTheme.colors.bgSubtleSecondary,
color = ElementTheme.colors.bgSubtleSecondary, )
)
}
UserStatusView( UserStatusView(
state = userStatusState, state = userStatusState,
emojiPickerRenderer = emojiPickerRenderer, emojiPickerRenderer = emojiPickerRenderer,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
HorizontalDivider(
thickness = 1.dp,
color = ElementTheme.colors.bgSubtleSecondary,
)
} }
/**
* Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=5414-4759
*/
@Composable @Composable
private fun ColumnScope.MultiAccountSection( private fun ColumnScope.MultiAccountSection(
state: PreferencesRootState, state: PreferencesRootState,
@@ -191,18 +206,84 @@ private fun ColumnScope.MultiAccountSection(
thickness = 8.dp, thickness = 8.dp,
color = ElementTheme.colors.bgSubtleSecondary, color = ElementTheme.colors.bgSubtleSecondary,
) )
state.otherSessions.forEach { matrixUser -> if (state.otherSessions.isEmpty()) {
MatrixUserRow( AddAccountItem(onAddAccountClick)
modifier = Modifier } else {
.clickable { val expandedStateDescription = if (state.isOtherAccountsSectionExpanded) {
state.eventSink(PreferencesRootEvent.SwitchToSession(matrixUser.userId)) stringResource(CommonStrings.a11y_state_expanded)
} else {
stringResource(CommonStrings.a11y_state_collapsed)
}
ListItem(
modifier = Modifier.semantics {
stateDescription = expandedStateDescription
},
content = { Text(stringResource(CommonStrings.common_switch_account)) },
onClick = { state.eventSink(PreferencesRootEvent.ToggleOtherAccountsExpanded) },
trailingContent = ListItemContent.Custom { _ ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AnimatedVisibility(
visible = !state.isOtherAccountsSectionExpanded,
enter = fadeIn(),
exit = fadeOut(),
) {
AvatarRow(
avatarDataList = state.otherSessions
.take(3)
.map { it.getAvatarData(AvatarSize.OtherAccountItem) }
.toImmutableList(),
avatarType = AvatarType.User,
lastOnTop = true,
)
}
// Animate the chevron icon to rotate when the section is expanded/collapsed
val rotation: Float by animateFloatAsState(
targetValue = if (state.isOtherAccountsSectionExpanded) -180f else 0f,
animationSpec = tween(
delayMillis = 0,
durationMillis = 300,
),
label = "chevron"
)
Icon(
modifier = Modifier.rotate(rotation),
imageVector = CompoundIcons.ChevronDown(),
contentDescription = null,
)
} }
.padding(top = 2.dp, bottom = 2.dp, end = 8.dp), },
matrixUser = matrixUser,
avatarSize = AvatarSize.AccountItem,
verticalSpaceWidth = 16.dp,
) )
AnimatedVisibility(
visible = state.isOtherAccountsSectionExpanded,
) {
Column {
HorizontalDivider(
thickness = 1.dp,
color = ElementTheme.colors.bgSubtleSecondary,
)
state.otherSessions.forEach { matrixUser ->
MatrixUserRow(
modifier = Modifier
.clickable {
state.eventSink(PreferencesRootEvent.SwitchToSession(matrixUser.userId))
}
.padding(top = 2.dp, bottom = 2.dp, end = 8.dp),
matrixUser = matrixUser,
avatarSize = AvatarSize.AccountItem,
verticalSpaceWidth = 16.dp,
)
}
AddAccountItem(onAddAccountClick)
}
}
} }
}
@Composable
private fun AddAccountItem(onAddAccountClick: () -> Unit) {
ListItem( ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Plus())), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Plus())),
content = { content = {
@@ -210,10 +291,6 @@ private fun ColumnScope.MultiAccountSection(
}, },
onClick = onAddAccountClick, onClick = onAddAccountClick,
) )
HorizontalDivider(
thickness = 8.dp,
color = ElementTheme.colors.bgSubtleSecondary,
)
} }
@Composable @Composable
@@ -7,12 +7,12 @@
package io.element.android.features.preferences.impl.userstatus package io.element.android.features.preferences.impl.userstatus
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@@ -72,47 +72,65 @@ fun UserStatusView(
emojiPickerRenderer: EmojiPickerRenderer, emojiPickerRenderer: EmojiPickerRenderer,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
when (val pickerState = state.pickerState) { AnimatedContent(
UserStatusPickerState.Hidden, UserStatusPickerState.ShowingPicker -> { targetState = state,
val displayedStatus = state.displayedStatus contentKey = {
if (displayedStatus != null) { when (it.pickerState) {
CurrentStatusRow( UserStatusPickerState.Hidden,
displayedStatus = displayedStatus, UserStatusPickerState.ShowingPicker -> if (state.displayedStatus != null) 0 else 1
onClick = { state.eventSink(UserStatusEvent.OpenPicker) }, is UserStatusPickerState.CustomInput -> 2
onClear = { state.eventSink(UserStatusEvent.ClearStatus) },
modifier = modifier,
)
} else {
EmptyStatusRow(
onClick = { state.eventSink(UserStatusEvent.OpenPicker) },
modifier = modifier,
)
} }
if (pickerState == UserStatusPickerState.ShowingPicker) { },
UserStatusPickerBottomSheet( ) { userStatusState ->
currentRawStatus = state.rawStatus, when (val pickerState = userStatusState.pickerState) {
onDismiss = { state.eventSink(UserStatusEvent.DismissPicker) }, UserStatusPickerState.Hidden,
onSelectPredefinedStatus = { status -> state.eventSink(UserStatusEvent.SetStatus(status)) }, UserStatusPickerState.ShowingPicker -> {
onSelectCustomStatus = { state.eventSink(UserStatusEvent.OpenCustomInput) }, val displayedStatus = state.displayedStatus
if (displayedStatus != null) {
CurrentStatusRow(
displayedStatus = displayedStatus,
onClick = { state.eventSink(UserStatusEvent.OpenPicker) },
onClear = { state.eventSink(UserStatusEvent.ClearStatus) },
modifier = modifier,
)
} else {
EmptyStatusRow(
onClick = { state.eventSink(UserStatusEvent.OpenPicker) },
modifier = modifier,
)
}
}
is UserStatusPickerState.CustomInput -> {
CustomStatusInputRow(
emoji = pickerState.emoji,
textFieldState = pickerState.textFieldState,
rawStatus = state.rawStatus,
onConfirm = {
state.eventSink(
UserStatusEvent.SetStatus(UserStatus(pickerState.emoji, pickerState.textFieldState.text.toString()))
)
},
onCancel = { state.eventSink(UserStatusEvent.CancelCustomInput) },
onEmojiClick = { state.eventSink(UserStatusEvent.OpenEmojiPicker) },
modifier = modifier,
) )
} }
} }
is UserStatusPickerState.CustomInput -> { }
CustomStatusInputRow( when (val pickerState = state.pickerState) {
emoji = pickerState.emoji, UserStatusPickerState.Hidden -> Unit
textFieldState = pickerState.textFieldState, UserStatusPickerState.ShowingPicker -> {
rawStatus = state.rawStatus, UserStatusPickerBottomSheet(
onConfirm = { currentRawStatus = state.rawStatus,
state.eventSink( onDismiss = { state.eventSink(UserStatusEvent.DismissPicker) },
UserStatusEvent.SetStatus(UserStatus(pickerState.emoji, pickerState.textFieldState.text.toString())) onSelectPredefinedStatus = { status -> state.eventSink(UserStatusEvent.SetStatus(status)) },
) onSelectCustomStatus = { state.eventSink(UserStatusEvent.OpenCustomInput) },
},
onCancel = { state.eventSink(UserStatusEvent.CancelCustomInput) },
onEmojiClick = { state.eventSink(UserStatusEvent.OpenEmojiPicker) },
modifier = modifier,
) )
}
is UserStatusPickerState.CustomInput -> {
when (val sheet = pickerState.emojiPickerSheetState) { when (val sheet = pickerState.emojiPickerSheetState) {
EmojiPickerSheetState.Hidden, EmojiPickerSheetState.Loading -> Unit EmojiPickerSheetState.Hidden,
EmojiPickerSheetState.Loading -> Unit
is EmojiPickerSheetState.Shown -> EmojiPickerBottomSheet( is EmojiPickerSheetState.Shown -> EmojiPickerBottomSheet(
pickerState = sheet.state, pickerState = sheet.state,
emojiPickerRenderer = emojiPickerRenderer, emojiPickerRenderer = emojiPickerRenderer,
@@ -133,7 +151,6 @@ private fun EmptyStatusRow(
content = { content = {
Text( Text(
text = stringResource(R.string.screen_settings_user_status_placeholder), text = stringResource(R.string.screen_settings_user_status_placeholder),
modifier = Modifier.padding(vertical = 16.dp),
) )
}, },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Reaction())), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Reaction())),
@@ -153,7 +170,7 @@ private fun CurrentStatusRow(
is DisplayedStatus.InCall -> "🎧" to stringResource(CommonStrings.common_on_a_call) is DisplayedStatus.InCall -> "🎧" to stringResource(CommonStrings.common_on_a_call)
} }
ListItem( ListItem(
content = { Text(text = text, modifier = Modifier.padding(vertical = 16.dp)) }, content = { Text(text = text) },
leadingContent = ListItemContent.Custom { EmojiText(emoji) }, leadingContent = ListItemContent.Custom { EmojiText(emoji) },
trailingContent = ListItemContent.Custom({ trailingContent = ListItemContent.Custom({
IconButton( IconButton(
@@ -37,17 +37,21 @@ import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.sessionstorage.api.SessionStore 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.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
@@ -82,6 +86,7 @@ class PreferencesRootPresenterTest {
) )
assertThat(initialState.version).isEqualTo("A Version") assertThat(initialState.version).isEqualTo("A Version")
assertThat(initialState.isMultiAccountEnabled).isFalse() assertThat(initialState.isMultiAccountEnabled).isFalse()
assertThat(initialState.isOtherAccountsSectionExpanded).isTrue()
assertThat(initialState.otherSessions).isEmpty() assertThat(initialState.otherSessions).isEmpty()
assertThat(initialState.version).isEqualTo("A Version") assertThat(initialState.version).isEqualTo("A Version")
val loadedState = awaitItem() val loadedState = awaitItem()
@@ -304,6 +309,43 @@ class PreferencesRootPresenterTest {
} }
} }
@Test
fun `present - other accounts section is collapsed if the preference says so`() = runTest {
createPresenter(
matrixClient = FakeMatrixClient(
canDeactivateAccountResult = { true },
accountManagementUrlResult = { Result.success(null) },
),
appPreferencesStore = InMemoryAppPreferencesStore(isOtherAccountsExpanded = false),
).test {
consumeItemsUntilPredicate { !it.isOtherAccountsSectionExpanded }
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - toggle other accounts expanded stores the new value`() = runTest {
val appPreferencesStore = InMemoryAppPreferencesStore()
createPresenter(
matrixClient = FakeMatrixClient(
canDeactivateAccountResult = { true },
accountManagementUrlResult = { Result.success(null) },
),
appPreferencesStore = appPreferencesStore,
).test {
val initialState = awaitFirstItem()
assertThat(initialState.isOtherAccountsSectionExpanded).isTrue()
initialState.eventSink(PreferencesRootEvent.ToggleOtherAccountsExpanded)
val collapsedState = consumeItemsUntilPredicate { !it.isOtherAccountsSectionExpanded }.last()
assertThat(appPreferencesStore.isOtherAccountsExpandedFlow().first()).isFalse()
// Toggling again must expand the section back
collapsedState.eventSink(PreferencesRootEvent.ToggleOtherAccountsExpanded)
consumeItemsUntilPredicate { it.isOtherAccountsSectionExpanded }
assertThat(appPreferencesStore.isOtherAccountsExpandedFlow().first()).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test @Test
fun `present - link new device`() = runTest { fun `present - link new device`() = runTest {
createPresenter( createPresenter(
@@ -333,6 +375,7 @@ class PreferencesRootPresenterTest {
indicatorService: IndicatorService = FakeIndicatorService(), indicatorService: IndicatorService = FakeIndicatorService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(), featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
sessionStore: SessionStore = InMemorySessionStore(), sessionStore: SessionStore = InMemorySessionStore(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
sessionEnterpriseService: SessionEnterpriseService = FakeSessionEnterpriseService(), sessionEnterpriseService: SessionEnterpriseService = FakeSessionEnterpriseService(),
) = PreferencesRootPresenter( ) = PreferencesRootPresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
@@ -346,6 +389,7 @@ class PreferencesRootPresenterTest {
rageshakeFeatureAvailability = rageshakeFeatureAvailability, rageshakeFeatureAvailability = rageshakeFeatureAvailability,
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
sessionStore = sessionStore, sessionStore = sessionStore,
appPreferencesStore = appPreferencesStore,
sessionEnterpriseService = sessionEnterpriseService, sessionEnterpriseService = sessionEnterpriseService,
userStatusPresenter = { aUserStatusState() }, userStatusPresenter = { aUserStatusState() },
) )
@@ -27,6 +27,8 @@ import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.assertNoNodeWithText
import io.element.android.tests.testutils.assertNodeWithTextIsDisplayed
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam import io.element.android.tests.testutils.ensureCalledOnceWithParam
@@ -71,6 +73,7 @@ class PreferencesRootViewTest : RobolectricTest() {
setView( setView(
aPreferencesRootState( aPreferencesRootState(
isMultiAccountEnabled = true, isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = true,
otherSessions = listOf( otherSessions = listOf(
aMatrixUser( aMatrixUser(
id = A_USER_ID_2.value, id = A_USER_ID_2.value,
@@ -84,6 +87,69 @@ class PreferencesRootViewTest : RobolectricTest() {
eventsRecorder.assertSingle(PreferencesRootEvent.SwitchToSession(A_USER_ID_2)) eventsRecorder.assertSingle(PreferencesRootEvent.SwitchToSession(A_USER_ID_2))
} }
@Test
fun `clicking on Switch accounts sends a ToggleOtherAccountsExpanded`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>()
setView(
aPreferencesRootState(
isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = false,
otherSessions = listOf(
aMatrixUser(
id = A_USER_ID_2.value,
displayName = "Bob",
)
),
eventSink = eventsRecorder,
),
)
clickOn(CommonStrings.common_switch_account)
eventsRecorder.assertSingle(PreferencesRootEvent.ToggleOtherAccountsExpanded)
}
@Test
fun `when the other accounts section is collapsed, other sessions and add account are not shown`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
setView(
aPreferencesRootState(
isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = false,
otherSessions = listOf(
aMatrixUser(
id = A_USER_ID_2.value,
displayName = "Bob",
)
),
eventSink = eventsRecorder,
),
)
assertNodeWithTextIsDisplayed(CommonStrings.common_switch_account)
onNodeWithText("Bob").assertDoesNotExist()
assertNoNodeWithText(CommonStrings.common_add_another_account)
}
@Test
fun `click on Add account of the expanded section invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
setView(
aPreferencesRootState(
isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = true,
otherSessions = listOf(
aMatrixUser(
id = A_USER_ID_2.value,
displayName = "Bob",
)
),
eventSink = eventsRecorder,
),
onAddAccountClick = callback,
)
clickOn(CommonStrings.common_add_another_account)
}
}
@Test @Test
fun `click on Add account invokes the expected callback`() = runAndroidComposeUiTest { fun `click on Add account invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
@@ -82,6 +82,7 @@ enum class AvatarSize(val dp: Dp) {
LeaveSpaceRoom(32.dp), LeaveSpaceRoom(32.dp),
SelectParentSpace(32.dp), SelectParentSpace(32.dp),
AccountItem(32.dp), AccountItem(32.dp),
OtherAccountItem(20.dp),
LocationPin(32.dp), LocationPin(32.dp),
ActiveCallItem(20.dp) ActiveCallItem(20.dp)
} }
@@ -44,6 +44,14 @@ interface AppPreferencesStore {
/** The name of the chosen theme, or `null` when the user has not picked one and the system setting should be followed. */ /** The name of the chosen theme, or `null` when the user has not picked one and the system setting should be followed. */
fun getThemeFlow(): Flow<String?> fun getThemeFlow(): Flow<String?>
/**
* @param expanded true to expand the other accounts section.
*/
suspend fun setOtherAccountsExpanded(expanded: Boolean)
/** Whether the other accounts section is expanded; defaults to `true`. */
fun isOtherAccountsExpandedFlow(): Flow<Boolean>
/** /**
* @param value the distance in metres the user must move before a new live location is published. * @param value the distance in metres the user must move before a new live location is published.
*/ */
@@ -33,6 +33,7 @@ private val developerModeKey = booleanPreferencesKey("developerMode")
private val customElementCallBaseUrlKey = stringPreferencesKey("elementCallBaseUrl") private val customElementCallBaseUrlKey = stringPreferencesKey("elementCallBaseUrl")
private val themeKey = stringPreferencesKey("theme") private val themeKey = stringPreferencesKey("theme")
private val hideInviteAvatarsKey = booleanPreferencesKey("hideInviteAvatars") private val hideInviteAvatarsKey = booleanPreferencesKey("hideInviteAvatars")
private val otherAccountsExpandedKey = booleanPreferencesKey("otherAccountsExpanded")
private val timelineMediaPreviewValueKey = stringPreferencesKey("timelineMediaPreviewValue") private val timelineMediaPreviewValueKey = stringPreferencesKey("timelineMediaPreviewValue")
private val liveLocationMinimumDistanceUpdateKey = intPreferencesKey("liveLocationMinimumDistanceUpdate") private val liveLocationMinimumDistanceUpdateKey = intPreferencesKey("liveLocationMinimumDistanceUpdate")
private val logLevelKey = stringPreferencesKey("logLevel") private val logLevelKey = stringPreferencesKey("logLevel")
@@ -97,6 +98,18 @@ class DefaultAppPreferencesStore(
} }
} }
override suspend fun setOtherAccountsExpanded(expanded: Boolean) {
store.edit { prefs ->
prefs[otherAccountsExpandedKey] = expanded
}
}
override fun isOtherAccountsExpandedFlow(): Flow<Boolean> {
return store.data.map { prefs ->
prefs[otherAccountsExpandedKey] ?: true
}
}
override suspend fun setLiveLocationMinimumDistanceInMetersUpdate(value: Int) { override suspend fun setLiveLocationMinimumDistanceInMetersUpdate(value: Int) {
store.edit { prefs -> store.edit { prefs ->
prefs[liveLocationMinimumDistanceUpdateKey] = value prefs[liveLocationMinimumDistanceUpdateKey] = value
@@ -120,6 +120,24 @@ class DefaultAppPreferencesStoreTest {
assertThat(history.last()).isEqualTo("https://server5.org") assertThat(history.last()).isEqualTo("https://server5.org")
} }
@Test
fun `other accounts section is expanded by default`() = runTest {
val store = createStore()
assertThat(store.isOtherAccountsExpandedFlow().first()).isTrue()
}
@Test
fun `other accounts expanded state persists updates`() = runTest {
val store = createStore()
store.setOtherAccountsExpanded(false)
assertThat(store.isOtherAccountsExpandedFlow().first()).isFalse()
store.setOtherAccountsExpanded(true)
assertThat(store.isOtherAccountsExpandedFlow().first()).isTrue()
}
private fun createStore() = DefaultAppPreferencesStore( private fun createStore() = DefaultAppPreferencesStore(
buildMeta = buildMeta, buildMeta = buildMeta,
preferenceDataStoreFactory = FakePreferenceDataStoreFactory(), preferenceDataStoreFactory = FakePreferenceDataStoreFactory(),
@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.updateAndGet
class InMemoryAppPreferencesStore( class InMemoryAppPreferencesStore(
isDeveloperModeEnabled: Boolean = false, isDeveloperModeEnabled: Boolean = false,
isOtherAccountsExpanded: Boolean = true,
customElementCallBaseUrl: String? = null, customElementCallBaseUrl: String? = null,
hideInviteAvatars: Boolean? = null, hideInviteAvatars: Boolean? = null,
timelineMediaPreviewValue: MediaPreviewValue? = null, timelineMediaPreviewValue: MediaPreviewValue? = null,
@@ -37,6 +38,7 @@ class InMemoryAppPreferencesStore(
callRingtoneDisplayName: String? = null, callRingtoneDisplayName: String? = null,
) : AppPreferencesStore { ) : AppPreferencesStore {
private val isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled) private val isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled)
private val isOtherAccountsExpanded = MutableStateFlow(isOtherAccountsExpanded)
private val customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl) private val customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl)
private val theme = MutableStateFlow(theme) private val theme = MutableStateFlow(theme)
private val liveLocationMinimumDistanceUpdate = MutableStateFlow(liveLocationMinimumDistanceUpdate) private val liveLocationMinimumDistanceUpdate = MutableStateFlow(liveLocationMinimumDistanceUpdate)
@@ -60,6 +62,14 @@ class InMemoryAppPreferencesStore(
return isDeveloperModeEnabled return isDeveloperModeEnabled
} }
override suspend fun setOtherAccountsExpanded(expanded: Boolean) {
isOtherAccountsExpanded.value = expanded
}
override fun isOtherAccountsExpandedFlow(): Flow<Boolean> {
return isOtherAccountsExpanded
}
override suspend fun setCustomElementCallBaseUrl(string: String?) { override suspend fun setCustomElementCallBaseUrl(string: String?) {
customElementCallBaseUrl.tryEmit(string) customElementCallBaseUrl.tryEmit(string)
} }
@@ -10,4 +10,5 @@
<string name="a11y_state_collapsed">"Collapsed"</string> <string name="a11y_state_collapsed">"Collapsed"</string>
<string name="a11y_active">Active</string> <string name="a11y_active">Active</string>
<string name="a11y_inactive">Inactive</string> <string name="a11y_inactive">Inactive</string>
<string name="common_switch_account">Switch accounts</string>
</resources> </resources>