Split settings: app settings / account settings

This commit is contained in:
Benoit Marty
2026-09-21 15:55:36 +02:00
committed by Benoit Marty
parent bec3780c9c
commit 9b2577f1dd
55 changed files with 1947 additions and 1153 deletions
@@ -40,8 +40,8 @@ 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.invite.api.InviteData
import io.element.android.libraries.designsystem.atomic.atoms.CopiableTextAtom
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewAliasAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewDescriptionAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewTitleAtom
import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule
@@ -514,7 +514,7 @@ private fun IncompleteContent(
title = {
when (roomIdOrAlias) {
is RoomIdOrAlias.Alias -> {
RoomPreviewAliasAtom(roomIdOrAlias.identifier)
CopiableTextAtom(roomIdOrAlias.identifier)
}
is RoomIdOrAlias.Id -> {
PlaceholderAtom(width = 200.dp, height = 22.dp)
@@ -567,7 +567,7 @@ private fun DefaultLoadedContent(
},
subtitle = {
if (contentState.alias != null) {
RoomPreviewAliasAtom(contentState.alias.value)
CopiableTextAtom(contentState.alias.value)
}
if (contentState.details is LoadedDetails.Space) {
Spacer(Modifier.height(8.dp))
@@ -26,11 +26,14 @@ import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.features.logout.api.LogoutEntryPoint
import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.features.preferences.impl.about.AboutNode
import io.element.android.features.preferences.impl.advanced.AdvancedSettingsNode
import io.element.android.features.preferences.impl.account.PreferencesAccountNode
import io.element.android.features.preferences.impl.analytics.AnalyticsSettingsNode
import io.element.android.features.preferences.impl.blockedusers.BlockedUsersNode
import io.element.android.features.preferences.impl.developer.DeveloperSettingsNode
import io.element.android.features.preferences.impl.labs.LabsNode
import io.element.android.features.preferences.impl.location.LocationSettingsNode
import io.element.android.features.preferences.impl.media.MediaSettingsNode
import io.element.android.features.preferences.impl.moderation.ModerationAndSafetyNode
import io.element.android.features.preferences.impl.notifications.NotificationSettingsNode
import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingNode
import io.element.android.features.preferences.impl.root.PreferencesRootNode
@@ -74,11 +77,17 @@ class PreferencesFlowNode(
@Parcelize
data object Root : NavTarget
@Parcelize
data object AccountSettings : NavTarget
@Parcelize
data object DeveloperSettings : NavTarget
@Parcelize
data object AdvancedSettings : NavTarget
data object MediaSettings : NavTarget
@Parcelize
data object LocationSettings : NavTarget
@Parcelize
data object Labs : NavTarget
@@ -92,6 +101,9 @@ class PreferencesFlowNode(
@Parcelize
data object NotificationSettings : NavTarget
@Parcelize
data object ModerationAndSafety : NavTarget
@Parcelize
data object TroubleshootNotifications : NavTarget
@@ -133,14 +145,6 @@ class PreferencesFlowNode(
callback.navigateToAddAccount()
}
override fun navigateToBugReport() {
callback.navigateToBugReport()
}
override fun navigateToSecureBackup() {
callback.navigateToSecureBackup()
}
override fun navigateToAnalyticsSettings() {
backstack.push(NavTarget.AnalyticsSettings)
}
@@ -153,24 +157,48 @@ class PreferencesFlowNode(
backstack.push(NavTarget.DeveloperSettings)
}
override fun navigateToNotificationSettings() {
backstack.push(NavTarget.NotificationSettings)
}
override fun navigateToLockScreenSettings() {
backstack.push(NavTarget.LockScreenSettings)
}
override fun navigateToAdvancedSettings() {
backstack.push(NavTarget.AdvancedSettings)
override fun navigateToMediaSettings() {
backstack.push(NavTarget.MediaSettings)
}
override fun navigateToLocationSettings() {
backstack.push(NavTarget.LocationSettings)
}
override fun navigateToLabs() {
backstack.push(NavTarget.Labs)
}
override fun navigateToAccountSettings() {
backstack.push(NavTarget.AccountSettings)
}
}
createNode<PreferencesRootNode>(buildContext, plugins = listOf(callback))
}
NavTarget.AccountSettings -> {
val callback = object : PreferencesAccountNode.Callback {
override fun navigateToBugReport() {
callback.navigateToBugReport()
}
override fun navigateToSecureBackup() {
callback.navigateToSecureBackup()
}
override fun navigateToModerationAndSafety() {
backstack.push(NavTarget.ModerationAndSafety)
}
override fun navigateToNotificationSettings() {
backstack.push(NavTarget.NotificationSettings)
}
override fun navigateToLinkNewDevice() {
callback.navigateToLinkNewDevice()
callback.navigateToAddAccount()
}
override fun navigateToUserProfile(matrixUser: MatrixUser) {
@@ -189,7 +217,7 @@ class PreferencesFlowNode(
backstack.push(NavTarget.AccountDeactivation)
}
}
createNode<PreferencesRootNode>(buildContext, plugins = listOf(callback))
createNode<PreferencesAccountNode>(buildContext, plugins = listOf(callback))
}
NavTarget.DeveloperSettings -> {
val developerSettingsCallback = object : DeveloperSettingsNode.Callback {
@@ -219,6 +247,12 @@ class PreferencesFlowNode(
}
createNode<LabsNode>(buildContext, listOf(callback))
}
NavTarget.ModerationAndSafety -> {
createNode<ModerationAndSafetyNode>(buildContext)
}
NavTarget.LocationSettings -> {
createNode<LocationSettingsNode>(buildContext)
}
NavTarget.About -> {
val callback = object : AboutNode.Callback {
override fun navigateToOssLicenses() {
@@ -309,8 +343,8 @@ class PreferencesFlowNode(
val input = EditDefaultNotificationSettingNode.Inputs(navTarget.isOneToOne)
createNode<EditDefaultNotificationSettingNode>(buildContext, plugins = listOf(input, callback))
}
NavTarget.AdvancedSettings -> {
createNode<AdvancedSettingsNode>(buildContext)
NavTarget.MediaSettings -> {
createNode<MediaSettingsNode>(buildContext)
}
is NavTarget.UserProfile -> {
val inputs = EditUserProfileNode.Inputs(navTarget.matrixUser)
@@ -0,0 +1,96 @@
/*
* 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.preferences.impl.account
import android.app.Activity
import androidx.activity.compose.LocalActivity
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.compound.theme.ElementTheme
import io.element.android.features.logout.api.direct.DirectLogoutEvent
import io.element.android.features.logout.api.direct.DirectLogoutView
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.user.MatrixUser
@ContributesNode(SessionScope::class)
@AssistedInject
class PreferencesAccountNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: PreferencesAccountPresenter,
private val directLogoutView: DirectLogoutView,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun navigateToBugReport()
fun navigateToSecureBackup()
fun navigateToModerationAndSafety()
fun navigateToNotificationSettings()
fun navigateToLinkNewDevice()
fun navigateToUserProfile(matrixUser: MatrixUser)
fun navigateToBlockedUsers()
fun startSignOutFlow()
fun startAccountDeactivationFlow()
}
private val callback: Callback = callback()
private fun onManageAccountClick(
activity: Activity,
url: String?,
isDark: Boolean,
) {
url?.let {
activity.openUrlInChromeCustomTab(
null,
darkTheme = isDark,
url = it
)
}
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val activity = requireNotNull(LocalActivity.current)
val isDark = ElementTheme.isLightTheme.not()
PreferencesAccountView(
state = state,
modifier = modifier,
onBackClick = this::navigateUp,
onAvatarClick = {
// TODO
},
onOpenRageShake = callback::navigateToBugReport,
onSecureBackupClick = callback::navigateToSecureBackup,
onModerationAndSafetyClick = callback::navigateToModerationAndSafety,
onLinkNewDeviceClick = callback::navigateToLinkNewDevice,
onManageAccountClick = { onManageAccountClick(activity, it, isDark) },
onOpenNotificationSettings = callback::navigateToNotificationSettings,
onEditProfileClick = callback::navigateToUserProfile,
onOpenBlockedUsers = callback::navigateToBlockedUsers,
onSignOutClick = {
if (state.directLogoutState.canDoDirectSignOut) {
state.directLogoutState.eventSink(DirectLogoutEvent.Logout(ignoreSdkError = false))
} else {
callback.startSignOutFlow()
}
},
onDeactivateClick = callback::startAccountDeactivationFlow
)
directLogoutView.Render(state = state.directLogoutState)
}
}
@@ -0,0 +1,111 @@
/*
* 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.preferences.impl.account
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.enterprise.api.SessionEnterpriseService
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
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.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@Inject
class PreferencesAccountPresenter(
private val matrixClient: MatrixClient,
private val sessionVerificationService: SessionVerificationService,
private val snackbarDispatcher: SnackbarDispatcher,
private val indicatorService: IndicatorService,
private val directLogoutPresenter: Presenter<DirectLogoutState>,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
private val featureFlagService: FeatureFlagService,
private val sessionEnterpriseService: SessionEnterpriseService,
) : Presenter<PreferencesAccountState> {
@Composable
override fun present(): PreferencesAccountState {
val matrixUser = matrixClient.userProfile.collectAsState()
LaunchedEffect(Unit) {
// Force a refresh of the profile
matrixClient.getUserProfile()
}
val showLinkNewDevice by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin)
}.collectAsState(initial = false)
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
// We should display the 'complete verification' option if the current session can be verified
val canVerifyUserSession by sessionVerificationService.needsSessionVerification.collectAsState(false)
val showSecureBackupIndicator by indicatorService.showSettingChatBackupIndicator()
val accountManagementUrl: MutableState<String?> = remember {
mutableStateOf(null)
}
var canDeactivateAccount by remember {
mutableStateOf(false)
}
val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false)
LaunchedEffect(Unit) {
canDeactivateAccount = matrixClient.canDeactivateAccount()
}
val nbOfBlockedUsers by produceState(initialValue = 0) {
matrixClient.ignoredUsersFlow
.onEach { value = it.size }
.launchIn(this)
}
val directLogoutState = directLogoutPresenter.present()
LaunchedEffect(Unit) {
initAccountManagementUrl(accountManagementUrl)
}
return PreferencesAccountState(
myUser = matrixUser.value,
showSecureBackup = !canVerifyUserSession,
showSecureBackupBadge = showSecureBackupIndicator,
accountManagementUrl = accountManagementUrl.value,
canReportBug = canReportBug,
showLinkNewDevice = showLinkNewDevice,
canDeactivateAccount = canDeactivateAccount,
nbOfBlockedUsers = nbOfBlockedUsers,
directLogoutState = directLogoutState,
snackbarMessage = snackbarMessage,
)
}
private fun CoroutineScope.initAccountManagementUrl(
accountManagementUrl: MutableState<String?>,
) = launch {
accountManagementUrl.value = matrixClient.getAccountManagementUrl(null)
.getOrNull()
?.let {
sessionEnterpriseService.tweakMasUrl(it)
}
}
}
@@ -0,0 +1,27 @@
/*
* 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.preferences.impl.account
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.user.MatrixUser
data class PreferencesAccountState(
val myUser: MatrixUser,
val showSecureBackup: Boolean,
val showSecureBackupBadge: Boolean,
val accountManagementUrl: String?,
val canReportBug: Boolean,
val showLinkNewDevice: Boolean,
val canDeactivateAccount: Boolean,
val nbOfBlockedUsers: Int,
val directLogoutState: DirectLogoutState,
val snackbarMessage: SnackbarMessage?,
) {
val showBlockedUsersItem = nbOfBlockedUsers > 0
}
@@ -0,0 +1,75 @@
/*
* 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.preferences.impl.account
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
open class PreferencesAccountStatePreviewParam : PreviewParameterProvider<PreferencesAccountState> {
override val values: Sequence<PreferencesAccountState>
get() = sequenceOf(
// Nominal state, that a regular user will see if multi account is enabled
aPreferencesAccountState(
myUser = aMatrixUser(avatarUrl = "anAvatarUrl"),
showSecureBackup = true,
accountManagementUrl = "aUrl",
canReportBug = true,
showLinkNewDevice = true,
canDeactivateAccount = false,
nbOfBlockedUsers = 3,
),
aPreferencesAccountState(
myUser = aMatrixUser(displayName = null),
showSecureBackup = true,
canDeactivateAccount = true,
),
aPreferencesAccountState(
accountManagementUrl = "aUrl",
showSecureBackup = true,
showSecureBackupBadge = true,
),
aPreferencesAccountState(
canReportBug = true,
nbOfBlockedUsers = 3,
snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete),
),
aPreferencesAccountState(
showLinkNewDevice = true,
canDeactivateAccount = true,
),
)
}
fun aPreferencesAccountState(
myUser: MatrixUser = aMatrixUser(),
showSecureBackup: Boolean = false,
showSecureBackupBadge: Boolean = false,
accountManagementUrl: String? = null,
canReportBug: Boolean = false,
showLinkNewDevice: Boolean = false,
canDeactivateAccount: Boolean = false,
nbOfBlockedUsers: Int = 0,
directLogoutState: DirectLogoutState = aDirectLogoutState(),
snackbarMessage: SnackbarMessage? = null,
) = PreferencesAccountState(
myUser = myUser,
showSecureBackup = showSecureBackup,
showSecureBackupBadge = showSecureBackupBadge,
accountManagementUrl = accountManagementUrl,
canReportBug = canReportBug,
showLinkNewDevice = showLinkNewDevice,
canDeactivateAccount = canDeactivateAccount,
nbOfBlockedUsers = nbOfBlockedUsers,
directLogoutState = directLogoutState,
snackbarMessage = snackbarMessage,
)
@@ -0,0 +1,224 @@
/*
* 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.preferences.impl.account
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
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.IconSource
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.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.UserHeaderView
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun PreferencesAccountView(
state: PreferencesAccountState,
onBackClick: () -> Unit,
onAvatarClick: (String) -> Unit,
onEditProfileClick: (MatrixUser) -> Unit,
onSecureBackupClick: () -> Unit,
onManageAccountClick: (url: String) -> Unit,
onLinkNewDeviceClick: () -> Unit,
onOpenRageShake: () -> Unit,
onModerationAndSafetyClick: () -> Unit,
onOpenNotificationSettings: () -> Unit,
onOpenBlockedUsers: () -> Unit,
onSignOutClick: () -> Unit,
onDeactivateClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
Box(modifier = modifier) {
PreferencePage(
onBackClick = onBackClick,
title = stringResource(id = CommonStrings.common_your_account),
snackbarHost = { SnackbarHost(snackbarHostState) }
) {
UserHeaderView(
matrixUser = state.myUser,
onAvatarClick = onAvatarClick,
)
// 'Account settings' section
ManageAccountSection(
state = state,
onManageAccountClick = onManageAccountClick,
onLinkNewDeviceClick = onLinkNewDeviceClick,
onEditProfileClick = onEditProfileClick,
onOpenBlockedUsers = onOpenBlockedUsers,
)
OtherSettingsSection(
state = state,
onOpenNotificationSettings = onOpenNotificationSettings,
onSecureBackupClick = onSecureBackupClick,
)
// General section
GeneralSection(
state = state,
onModerationAndSafetyClick = onModerationAndSafetyClick,
onOpenRageShake = onOpenRageShake,
onSignOutClick = onSignOutClick,
onDeactivateClick = onDeactivateClick,
)
}
}
}
@Composable
private fun ManageAccountSection(
state: PreferencesAccountState,
onEditProfileClick: (MatrixUser) -> Unit,
onManageAccountClick: (url: String) -> Unit,
onLinkNewDeviceClick: () -> Unit,
onOpenBlockedUsers: () -> Unit,
) {
PreferenceCategory(
title = stringResource(CommonStrings.common_account_settings),
) {
ListItem(
content = { Text(stringResource(id = R.string.screen_edit_profile_title)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())),
onClick = { onEditProfileClick(state.myUser) },
)
state.accountManagementUrl?.let { url ->
ListItem(
content = { Text(stringResource(id = CommonStrings.action_manage_account_and_devices)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut())),
onClick = { onManageAccountClick(url) },
)
}
if (state.showLinkNewDevice) {
ListItem(
content = { Text(stringResource(id = CommonStrings.common_link_new_device)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Devices())),
onClick = onLinkNewDeviceClick,
)
}
if (state.showBlockedUsersItem) {
ListItem(
content = { Text(stringResource(id = CommonStrings.common_blocked_users)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())),
onClick = onOpenBlockedUsers,
trailingContent = ListItemContent.Text(state.nbOfBlockedUsers.toString()),
)
}
}
}
@Composable
private fun ColumnScope.OtherSettingsSection(
state: PreferencesAccountState,
onOpenNotificationSettings: () -> Unit,
onSecureBackupClick: () -> Unit,
) {
HorizontalDivider()
ListItem(
content = { Text(stringResource(id = R.string.screen_notification_settings_title)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Notifications())),
onClick = onOpenNotificationSettings,
)
if (state.showSecureBackup) {
ListItem(
content = { Text(stringResource(id = CommonStrings.common_encryption)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Key())),
trailingContent = ListItemContent.Badge.takeIf { state.showSecureBackupBadge },
onClick = onSecureBackupClick,
)
}
}
@Composable
private fun ColumnScope.GeneralSection(
state: PreferencesAccountState,
onModerationAndSafetyClick: () -> Unit,
onOpenRageShake: () -> Unit,
onSignOutClick: () -> Unit,
onDeactivateClick: () -> Unit,
) {
HorizontalDivider()
ListItem(
content = { Text(stringResource(id = CommonStrings.common_moderation_and_safety)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Admin())),
onClick = onModerationAndSafetyClick,
)
if (state.canReportBug) {
ListItem(
content = { Text(stringResource(id = CommonStrings.common_report_a_problem)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChatProblem())),
onClick = onOpenRageShake,
)
}
ListItem(
content = { Text(stringResource(id = CommonStrings.action_signout)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Close())),
style = ListItemStyle.Destructive,
onClick = onSignOutClick,
)
if (state.canDeactivateAccount) {
ListItem(
content = { Text(stringResource(id = CommonStrings.action_delete_account)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Delete())),
style = ListItemStyle.Destructive,
onClick = onDeactivateClick,
)
}
}
@PreviewWithLargeHeight
@Composable
internal fun PreferencesAccountViewLightPreview(@PreviewParameter(PreferencesAccountStatePreviewParam::class) state: PreferencesAccountState) =
ElementPreviewLight(
drawableFallbackForImages = CommonDrawables.sample_avatar,
) { ContentToPreview(state) }
@PreviewWithLargeHeight
@Composable
internal fun PreferencesAccountViewDarkPreview(@PreviewParameter(PreferencesAccountStatePreviewParam::class) state: PreferencesAccountState) =
ElementPreviewDark(
drawableFallbackForImages = CommonDrawables.sample_avatar,
) { ContentToPreview(state) }
@ExcludeFromCoverage
@Composable
private fun ContentToPreview(state: PreferencesAccountState) {
PreferencesAccountView(
state = state,
onBackClick = {},
onAvatarClick = {},
onEditProfileClick = {},
onModerationAndSafetyClick = {},
onOpenRageShake = {},
onSecureBackupClick = {},
onManageAccountClick = {},
onLinkNewDeviceClick = {},
onOpenNotificationSettings = {},
onOpenBlockedUsers = {},
onSignOutClick = {},
onDeactivateClick = {},
)
}
@@ -1,24 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.preferences.impl.advanced
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
sealed interface AdvancedSettingsEvent {
data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvent
data class SetSharePresenceEnabled(val enabled: Boolean) : AdvancedSettingsEvent
data class SetCompressMedia(val compress: Boolean) : AdvancedSettingsEvent
data class SetCompressImages(val compress: Boolean) : AdvancedSettingsEvent
data class SetVideoUploadQuality(val videoPreset: VideoCompressionPreset) : AdvancedSettingsEvent
data class SetTheme(val theme: ThemeOption) : AdvancedSettingsEvent
data class SetTimelineMediaPreviewValue(val value: MediaPreviewValue) : AdvancedSettingsEvent
data class SetHideInviteAvatars(val value: Boolean) : AdvancedSettingsEvent
data class SetLiveLocationMinimumDistanceUpdate(val value: Int) : AdvancedSettingsEvent
}
@@ -1,147 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.preferences.impl.advanced
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import dev.zacsweers.metro.Inject
import io.element.android.compound.theme.Theme
import io.element.android.compound.theme.mapToTheme
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
@Inject
class AdvancedSettingsPresenter(
private val appPreferencesStore: AppPreferencesStore,
private val sessionPreferencesStore: SessionPreferencesStore,
private val mediaPreviewConfigStateStore: MediaPreviewConfigStateStore,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val featureFlagService: FeatureFlagService,
) : Presenter<AdvancedSettingsState> {
@Composable
override fun present(): AdvancedSettingsState {
val isDeveloperModeEnabled by remember {
appPreferencesStore.isDeveloperModeEnabledFlow()
}.collectAsState(initial = false)
val isSharePresenceEnabled by remember {
sessionPreferencesStore.isSharePresenceEnabled()
}.collectAsState(initial = true)
val isBlackThemeAllowed by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.AllowBlackTheme)
}.collectAsState(initial = false)
val theme = remember(isBlackThemeAllowed) {
appPreferencesStore.getThemeFlow().mapToTheme(isBlackThemeAllowed)
}.collectAsState(initial = Theme.System)
val liveLocationMinimumDistanceUpdate by produceState<Int?>(null) {
appPreferencesStore.getLiveLocationMinimumDistanceInMetersUpdateFlow().collect { value = it }
}
val mediaPreviewConfigState = mediaPreviewConfigStateStore.state()
val themeOption by remember {
derivedStateOf {
when (theme.value) {
Theme.System -> ThemeOption.System
Theme.Dark -> ThemeOption.Dark
Theme.Black -> ThemeOption.Black
Theme.Light -> ThemeOption.Light
}
}
}
val hasSplitMediaQualityOptions by produceState<Boolean?>(null) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.SelectableMediaQuality)
}
val availableThemeOptions = remember(isBlackThemeAllowed) {
if (isBlackThemeAllowed) {
ThemeOption.entries
} else {
ThemeOption.entries.filterNot { it == ThemeOption.Black }
}.toImmutableList()
}
val mediaOptimizationState by produceState<MediaOptimizationState?>(null) {
val hasSplitMediaQualityOptionsFlow = featureFlagService.isFeatureEnabledFlow(FeatureFlags.SelectableMediaQuality)
combine(
hasSplitMediaQualityOptionsFlow,
sessionPreferencesStore.doesOptimizeImages(),
sessionPreferencesStore.getVideoCompressionPreset()
) { hasSplitOptions, compressImages, videoPreset ->
if (hasSplitMediaQualityOptions == true) {
value = MediaOptimizationState.Split(
compressImages = compressImages,
videoPreset = videoPreset,
)
} else if (hasSplitMediaQualityOptions == false) {
value = MediaOptimizationState.AllMedia(isEnabled = compressImages)
}
}.collect()
}
fun handleEvent(event: AdvancedSettingsEvent) {
when (event) {
is AdvancedSettingsEvent.SetDeveloperModeEnabled -> sessionCoroutineScope.launch {
appPreferencesStore.setDeveloperModeEnabled(event.enabled)
}
is AdvancedSettingsEvent.SetSharePresenceEnabled -> sessionCoroutineScope.launch {
sessionPreferencesStore.setSharePresence(event.enabled)
}
is AdvancedSettingsEvent.SetCompressMedia -> sessionCoroutineScope.launch {
sessionPreferencesStore.setOptimizeImages(event.compress)
}
is AdvancedSettingsEvent.SetTheme -> sessionCoroutineScope.launch {
when (event.theme) {
ThemeOption.System -> appPreferencesStore.setTheme(Theme.System.name)
ThemeOption.Dark -> appPreferencesStore.setTheme(Theme.Dark.name)
ThemeOption.Black -> appPreferencesStore.setTheme(Theme.Black.name)
ThemeOption.Light -> appPreferencesStore.setTheme(Theme.Light.name)
}
}
is AdvancedSettingsEvent.SetHideInviteAvatars -> mediaPreviewConfigStateStore.setHideInviteAvatars(event.value)
is AdvancedSettingsEvent.SetTimelineMediaPreviewValue -> mediaPreviewConfigStateStore.setTimelineMediaPreviewValue(event.value)
is AdvancedSettingsEvent.SetLiveLocationMinimumDistanceUpdate -> sessionCoroutineScope.launch {
appPreferencesStore.setLiveLocationMinimumDistanceInMetersUpdate(event.value)
}
is AdvancedSettingsEvent.SetCompressImages -> sessionCoroutineScope.launch {
sessionPreferencesStore.setOptimizeImages(event.compress)
}
is AdvancedSettingsEvent.SetVideoUploadQuality -> sessionCoroutineScope.launch {
sessionPreferencesStore.setVideoCompressionPreset(event.videoPreset)
}
}
}
return AdvancedSettingsState(
isDeveloperModeEnabled = isDeveloperModeEnabled,
isSharePresenceEnabled = isSharePresenceEnabled,
mediaOptimizationState = mediaOptimizationState,
theme = themeOption,
availableThemeOptions = availableThemeOptions,
mediaPreviewConfigState = mediaPreviewConfigState,
liveLocationMinimumDistanceUpdate = liveLocationMinimumDistanceUpdate,
eventSink = ::handleEvent,
)
}
}
@@ -1,67 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.preferences.impl.advanced
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.res.stringResource
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.designsystem.components.preferences.DropdownOption
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import kotlinx.collections.immutable.ImmutableList
data class AdvancedSettingsState(
val isDeveloperModeEnabled: Boolean,
val isSharePresenceEnabled: Boolean,
val mediaOptimizationState: MediaOptimizationState?,
val theme: ThemeOption,
val availableThemeOptions: ImmutableList<ThemeOption>,
val mediaPreviewConfigState: MediaPreviewConfigState,
val liveLocationMinimumDistanceUpdate: Int?,
val eventSink: (AdvancedSettingsEvent) -> Unit
)
sealed interface MediaOptimizationState {
data class AllMedia(val isEnabled: Boolean) : MediaOptimizationState
data class Split(
val compressImages: Boolean,
val videoPreset: VideoCompressionPreset,
) : MediaOptimizationState
val shouldCompressImages: Boolean get() = when (this) {
is AllMedia -> isEnabled
is Split -> compressImages
}
}
enum class ThemeOption : DropdownOption {
System {
@Composable
@ReadOnlyComposable
override fun getText(): String = stringResource(R.string.theme_system)
},
Light {
@Composable
@ReadOnlyComposable
override fun getText(): String = stringResource(R.string.theme_light)
},
Dark {
@Composable
@ReadOnlyComposable
override fun getText(): String = stringResource(R.string.theme_dark)
},
Black {
@Composable
@ReadOnlyComposable
override fun getText(): String = stringResource(R.string.theme_black)
}
}
@@ -1,62 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.preferences.impl.advanced
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
open class AdvancedSettingsStatePreviewParam : PreviewParameterProvider<AdvancedSettingsState> {
override val values: Sequence<AdvancedSettingsState>
get() = sequenceOf(
aAdvancedSettingsState(),
aAdvancedSettingsState(isDeveloperModeEnabled = true),
aAdvancedSettingsState(isSharePresenceEnabled = true),
aAdvancedSettingsState(mediaOptimizationState = MediaOptimizationState.AllMedia(isEnabled = true)),
aAdvancedSettingsState(hideInviteAvatars = true),
aAdvancedSettingsState(timelineMediaPreviewValue = MediaPreviewValue.Off),
aAdvancedSettingsState(setHideInviteAvatarsAction = AsyncAction.Loading),
aAdvancedSettingsState(setTimelineMediaPreviewAction = AsyncAction.Loading),
aAdvancedSettingsState(mediaOptimizationState = MediaOptimizationState.Split(
compressImages = true,
videoPreset = VideoCompressionPreset.HIGH,
)),
)
}
fun aAdvancedSettingsState(
isDeveloperModeEnabled: Boolean = false,
isSharePresenceEnabled: Boolean = false,
mediaOptimizationState: MediaOptimizationState = MediaOptimizationState.AllMedia(isEnabled = false),
theme: ThemeOption = ThemeOption.System,
availableThemeOptions: ImmutableList<ThemeOption> = ThemeOption.entries.toImmutableList(),
hideInviteAvatars: Boolean = false,
timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On,
liveLocationMinimumDistanceUpdate: Int? = 50,
setTimelineMediaPreviewAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
setHideInviteAvatarsAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (AdvancedSettingsEvent) -> Unit = {},
) = AdvancedSettingsState(
isDeveloperModeEnabled = isDeveloperModeEnabled,
isSharePresenceEnabled = isSharePresenceEnabled,
mediaOptimizationState = mediaOptimizationState,
theme = theme,
availableThemeOptions = availableThemeOptions,
mediaPreviewConfigState = MediaPreviewConfigState(
hideInviteAvatars = hideInviteAvatars,
timelineMediaPreviewValue = timelineMediaPreviewValue,
setTimelineMediaPreviewAction = setTimelineMediaPreviewAction,
setHideInviteAvatarsAction = setHideInviteAvatarsAction
),
liveLocationMinimumDistanceUpdate = liveLocationMinimumDistanceUpdate,
eventSink = eventSink
)
@@ -1,446 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.preferences.impl.advanced
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.SliderDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.components.dialogs.ListDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.ElementPreviewBlack
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.stringWithLink
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
import io.element.android.libraries.designsystem.theme.components.ListSupportingText
import io.element.android.libraries.designsystem.theme.components.ListSupportingTextDefaults
import io.element.android.libraries.designsystem.theme.components.Slider
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.compose.LocalAnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlin.math.roundToInt
@Composable
fun AdvancedSettingsView(
state: AdvancedSettingsState,
onBackClick: () -> Unit,
onOpenAppSettingsClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val analyticsService = LocalAnalyticsService.current
val snackbarDispatcher = LocalSnackbarDispatcher.current
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = snackbarMessage)
PreferencePage(
modifier = modifier,
onBackClick = onBackClick,
title = stringResource(id = CommonStrings.common_advanced_settings),
snackbarHost = {
SnackbarHost(
snackbarHostState,
modifier = Modifier.navigationBarsPadding()
)
}
) {
PreferenceDropdown(
title = stringResource(id = CommonStrings.common_appearance),
selectedOption = state.theme,
options = state.availableThemeOptions,
onSelectOption = { themeOption ->
state.eventSink(AdvancedSettingsEvent.SetTheme(themeOption))
}
)
ListItem(
content = {
Text(text = stringResource(id = CommonStrings.action_view_source))
},
supportingContent = {
Text(text = stringResource(id = R.string.screen_advanced_settings_view_source_description))
},
trailingContent = ListItemContent.Switch(
checked = state.isDeveloperModeEnabled,
),
onClick = { state.eventSink(AdvancedSettingsEvent.SetDeveloperModeEnabled(!state.isDeveloperModeEnabled)) }
)
ListItem(
content = {
Text(text = stringResource(id = R.string.screen_advanced_settings_share_presence))
},
supportingContent = {
Text(text = stringResource(id = R.string.screen_advanced_settings_share_presence_description))
},
trailingContent = ListItemContent.Switch(
checked = state.isSharePresenceEnabled,
),
onClick = { state.eventSink(AdvancedSettingsEvent.SetSharePresenceEnabled(!state.isSharePresenceEnabled)) }
)
val compressImages = state.mediaOptimizationState?.shouldCompressImages
when (state.mediaOptimizationState) {
null -> Unit
is MediaOptimizationState.AllMedia -> {
ListItem(
content = {
Text(text = stringResource(id = R.string.screen_advanced_settings_media_compression_title))
},
supportingContent = {
Text(text = stringResource(id = R.string.screen_advanced_settings_media_compression_description))
},
trailingContent = ListItemContent.Switch(
checked = compressImages ?: false,
),
onClick = {
val newValue = !(compressImages ?: false)
analyticsService.captureInteraction(
if (newValue) {
Interaction.Name.MobileSettingsOptimizeMediaUploadsEnabled
} else {
Interaction.Name.MobileSettingsOptimizeMediaUploadsDisabled
}
)
state.eventSink(AdvancedSettingsEvent.SetCompressMedia(newValue))
}
)
}
is MediaOptimizationState.Split -> {
ListItem(
content = {
Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_image_upload_quality_title))
},
supportingContent = {
Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_image_upload_quality_description))
},
trailingContent = ListItemContent.Switch(
checked = compressImages ?: false,
),
onClick = {
val newValue = !(compressImages ?: false)
analyticsService.captureInteraction(
if (newValue) {
Interaction.Name.MobileSettingsOptimizeMediaUploadsEnabled
} else {
Interaction.Name.MobileSettingsOptimizeMediaUploadsDisabled
}
)
state.eventSink(AdvancedSettingsEvent.SetCompressMedia(newValue))
}
)
var displaySelectorDialog by remember { mutableStateOf(false) }
ListItem(
content = {
Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_title))
},
supportingContent = {
val description = stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_description)
val quality = when (state.mediaOptimizationState.videoPreset) {
VideoCompressionPreset.LOW -> stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_low)
VideoCompressionPreset.STANDARD -> stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_standard)
VideoCompressionPreset.HIGH -> stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_high)
}
val descriptionWithValue = remember(quality) {
String.format(description, quality)
}
Text(text = descriptionWithValue)
},
onClick = { displaySelectorDialog = true },
)
if (displaySelectorDialog) {
VideoQualitySelectorDialog(
selectedPreset = state.mediaOptimizationState.videoPreset,
onSubmit = { preset ->
state.eventSink(AdvancedSettingsEvent.SetVideoUploadQuality(preset))
displaySelectorDialog = false
},
onDismiss = { displaySelectorDialog = false },
)
}
}
}
ModerationAndSafety(state)
if (state.liveLocationMinimumDistanceUpdate != null) {
LiveLocationUpdatesSection(
value = state.liveLocationMinimumDistanceUpdate,
onSaveValue = { value ->
state.eventSink(AdvancedSettingsEvent.SetLiveLocationMinimumDistanceUpdate(value))
},
onOpenAppPermissionsClick = onOpenAppSettingsClick,
)
}
}
}
@Composable
private fun VideoQualitySelectorDialog(
selectedPreset: VideoCompressionPreset,
onSubmit: (VideoCompressionPreset) -> Unit,
onDismiss: () -> Unit
) {
val videoPresets = VideoCompressionPreset.entries
var localSelectedPreset by remember { mutableStateOf(selectedPreset) }
ListDialog(
title = stringResource(CommonStrings.dialog_video_quality_selector_title),
subtitle = stringResource(CommonStrings.dialog_default_video_quality_selector_subtitle),
onSubmit = { onSubmit(localSelectedPreset) },
onDismissRequest = onDismiss,
applyPaddingToContents = false,
) {
for (preset in videoPresets) {
val isSelected = preset == localSelectedPreset
item(
key = preset,
contentType = preset,
) {
val title = when (preset) {
VideoCompressionPreset.LOW -> stringResource(R.string.screen_advanced_settings_optimise_video_upload_quality_low)
VideoCompressionPreset.STANDARD -> stringResource(R.string.screen_advanced_settings_optimise_video_upload_quality_standard)
VideoCompressionPreset.HIGH -> stringResource(R.string.screen_advanced_settings_optimise_video_upload_quality_high)
}
val subtitle = when (preset) {
VideoCompressionPreset.LOW -> stringResource(CommonStrings.common_video_quality_low_description)
VideoCompressionPreset.STANDARD -> stringResource(CommonStrings.common_video_quality_standard_description)
VideoCompressionPreset.HIGH -> stringResource(CommonStrings.common_video_quality_high_description)
}
ListItem(
content = {
Text(
text = title,
style = ElementTheme.typography.fontBodyLgMedium,
)
},
supportingContent = {
Text(
text = subtitle,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
},
leadingContent = ListItemContent.RadioButton(
selected = isSelected,
),
onClick = {
localSelectedPreset = preset
},
)
}
}
}
}
@Composable
private fun ModerationAndSafety(
state: AdvancedSettingsState,
modifier: Modifier = Modifier,
) {
PreferenceCategory(
modifier = modifier,
title = stringResource(R.string.screen_advanced_settings_moderation_and_safety_section_title),
showTopDivider = true
) {
PreferenceSwitch(
title = stringResource(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title),
isChecked = state.mediaPreviewConfigState.hideInviteAvatars,
onCheckedChange = {
state.eventSink(AdvancedSettingsEvent.SetHideInviteAvatars(it))
},
enabled = !state.mediaPreviewConfigState.setHideInviteAvatarsAction.isLoading()
)
ListSectionHeader(
title = stringResource(R.string.screen_advanced_settings_show_media_timeline_title),
hasDivider = false,
description = {
ListSupportingText(
text = stringResource(R.string.screen_advanced_settings_show_media_timeline_subtitle),
contentPadding = ListSupportingTextDefaults.Padding.None,
)
}
)
ListItem(
content = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_hide)) },
leadingContent = ListItemContent.RadioButton(
selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.Off,
compact = true
),
onClick = {
state.eventSink(AdvancedSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Off))
},
enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading()
)
ListItem(
content = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_private_rooms)) },
leadingContent = ListItemContent.RadioButton(
selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.Private,
compact = true
),
onClick = {
state.eventSink(AdvancedSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Private))
},
enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading()
)
ListItem(
content = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_show)) },
leadingContent = ListItemContent.RadioButton(
selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.On,
compact = true
),
onClick = {
state.eventSink(AdvancedSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.On))
},
enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading()
)
}
}
@Composable
private fun LiveLocationUpdatesSection(
value: Int,
onSaveValue: (Int) -> Unit,
onOpenAppPermissionsClick: () -> Unit,
modifier: Modifier = Modifier,
) {
PreferenceCategory(
modifier = modifier,
showTopDivider = true,
) {
ListSectionHeader(
title = stringResource(R.string.screen_advanced_settings_live_location_section_title),
description = {
ListSupportingText(
text = stringResource(R.string.screen_advanced_settings_live_location_section_description),
contentPadding = ListSupportingTextDefaults.Padding.None,
)
}
)
var sliderValue by remember(value) { mutableIntStateOf(value) }
Column(
modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = pluralStringResource(
R.plurals.screen_advanced_settings_live_location_update_distance,
sliderValue,
sliderValue,
),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
)
val valueRange = 1f..100f
val start = valueRange.start.toInt()
val end = valueRange.endInclusive.toInt()
Row(verticalAlignment = Alignment.CenterVertically) {
Text("${start}m", color = ElementTheme.colors.textSecondary, style = ElementTheme.typography.fontBodyMdRegular)
Slider(
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp),
value = sliderValue.toFloat(),
onValueChange = { sliderValue = it.roundToInt() },
onValueChangeFinish = {
onSaveValue(sliderValue)
},
valueRange = valueRange,
colors = SliderDefaults.colors(
thumbColor = ElementTheme.colors.iconAccentPrimary,
activeTrackColor = ElementTheme.colors.iconAccentPrimary,
inactiveTrackColor = ElementTheme.colors.bgBadgeAccent,
inactiveTickColor = ElementTheme.colors.iconAccentPrimary,
)
)
Text("${end}m", color = ElementTheme.colors.textSecondary, style = ElementTheme.typography.fontBodyMdRegular)
}
}
val footerText = stringWithLink(
textRes = R.string.screen_advanced_settings_live_location_section_footer,
url = "",
linkTextRes = R.string.screen_advanced_settings_live_location_section_footer_link,
onLinkClick = { onOpenAppPermissionsClick() },
)
ListSupportingText(
annotatedString = footerText,
contentPadding = ListSupportingTextDefaults.Padding.Default,
)
}
}
@PreviewWithLargeHeight
@Composable
internal fun AdvancedSettingsViewLightPreview(@PreviewParameter(AdvancedSettingsStatePreviewParam::class) state: AdvancedSettingsState) =
ElementPreviewLight { ContentToPreview(state) }
@PreviewWithLargeHeight
@Composable
internal fun AdvancedSettingsViewDarkPreview(@PreviewParameter(AdvancedSettingsStatePreviewParam::class) state: AdvancedSettingsState) =
ElementPreviewDark { ContentToPreview(state) }
@PreviewWithLargeHeight
@Composable
internal fun AdvancedSettingsViewBlackPreview(@PreviewParameter(AdvancedSettingsStatePreviewParam::class) state: AdvancedSettingsState) =
ElementPreviewBlack { ContentToPreview(state) }
@ExcludeFromCoverage
@Composable
private fun ContentToPreview(state: AdvancedSettingsState) {
AdvancedSettingsView(
state = state,
onBackClick = { },
onOpenAppSettingsClick = {}
)
}
@Composable
@PreviewsDayNight
internal fun VideoQualitySelectorDialogPreview() {
ElementPreview {
VideoQualitySelectorDialog(
selectedPreset = VideoCompressionPreset.STANDARD,
onSubmit = { /* no-op */ },
onDismiss = { /* no-op */ }
)
}
}
@@ -12,6 +12,7 @@ import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
import io.element.android.libraries.matrix.api.tracing.TraceLogPack
sealed interface AppDeveloperSettingsEvent {
data class SetDeveloperModeEnabled(val enabled: Boolean) : AppDeveloperSettingsEvent
data class UpdateEnabledFeature(val feature: FeatureUiModel, val isEnabled: Boolean) : AppDeveloperSettingsEvent
data class SetCustomElementCallBaseUrl(val baseUrl: String?) : AppDeveloperSettingsEvent
data class SetTracingLogLevel(val logLevel: LogLevelItem) : AppDeveloperSettingsEvent
@@ -47,6 +47,9 @@ class AppDeveloperSettingsPresenter(
) : Presenter<AppDeveloperSettingsState> {
@Composable
override fun present(): AppDeveloperSettingsState {
val isDeveloperModeEnabled by remember {
appPreferencesStore.isDeveloperModeEnabledFlow()
}.collectAsState(initial = false)
val rageshakeState = rageshakePresenter.present()
val enabledFeatures = remember {
mutableStateListOf<EnabledFeature>()
@@ -79,6 +82,9 @@ class AppDeveloperSettingsPresenter(
fun handleEvent(event: AppDeveloperSettingsEvent) {
when (event) {
is AppDeveloperSettingsEvent.SetDeveloperModeEnabled -> coroutineScope.launch {
appPreferencesStore.setDeveloperModeEnabled(event.enabled)
}
is AppDeveloperSettingsEvent.UpdateEnabledFeature -> coroutineScope.updateEnabledFeature(
enabledFeatures = enabledFeatures,
featureKey = event.feature.key,
@@ -104,6 +110,7 @@ class AppDeveloperSettingsPresenter(
}
return AppDeveloperSettingsState(
isDeveloperModeEnabled = isDeveloperModeEnabled,
features = featureUiModels,
rageshakeState = rageshakeState,
customElementCallBaseUrlState = CustomElementCallBaseUrlState(
@@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.tracing.TraceLogPack
import kotlinx.collections.immutable.ImmutableList
data class AppDeveloperSettingsState(
val isDeveloperModeEnabled: Boolean,
val features: ImmutableList<FeatureUiModel>,
val rageshakeState: RageshakePreferencesState,
val customElementCallBaseUrlState: CustomElementCallBaseUrlState,
@@ -24,14 +24,19 @@ open class AppDeveloperSettingsStatePreviewParam : PreviewParameterProvider<AppD
baseUrl = "https://call.element.ahoy",
)
),
anAppDeveloperSettingsState(
isDeveloperModeEnabled = true,
),
)
}
fun anAppDeveloperSettingsState(
isDeveloperModeEnabled: Boolean = false,
customElementCallBaseUrlState: CustomElementCallBaseUrlState = aCustomElementCallBaseUrlState(),
traceLogPacks: List<TraceLogPack> = emptyList(),
eventSink: (AppDeveloperSettingsEvent) -> Unit = {},
) = AppDeveloperSettingsState(
isDeveloperModeEnabled = isDeveloperModeEnabled,
features = aFeatureUiModelList(),
rageshakeState = aRageshakePreferencesState(),
customElementCallBaseUrlState = customElementCallBaseUrlState,
@@ -24,6 +24,7 @@ import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesView
import io.element.android.libraries.androidutils.system.copyToClipboard
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
@@ -48,6 +49,18 @@ fun AppDeveloperSettingsView(
modifier = modifier.fillMaxWidth(),
) {
// Note: this is OK to hardcode strings in this debug screen.
ListItem(
content = {
Text(text = stringResource(id = CommonStrings.action_view_source))
},
supportingContent = {
Text(text = stringResource(id = R.string.screen_advanced_settings_view_source_description))
},
trailingContent = ListItemContent.Switch(
checked = state.isDeveloperModeEnabled,
),
onClick = { state.eventSink(AppDeveloperSettingsEvent.SetDeveloperModeEnabled(!state.isDeveloperModeEnabled)) }
)
PreferenceCategory(
title = "Feature flags",
showTopDivider = false,
@@ -0,0 +1,12 @@
/*
* 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.preferences.impl.location
sealed interface LocationSettingsEvent {
data class SetLiveLocationMinimumDistanceUpdate(val value: Int) : LocationSettingsEvent
}
@@ -1,12 +1,11 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
* 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.preferences.impl.advanced
package io.element.android.features.preferences.impl.location
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -22,20 +21,20 @@ import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
@AssistedInject
class AdvancedSettingsNode(
class LocationSettingsNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: AdvancedSettingsPresenter,
private val presenter: LocationSettingsPresenter,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val context = LocalContext.current
AdvancedSettingsView(
LocationSettingsView(
state = state,
modifier = modifier,
onBackClick = ::navigateUp,
onOpenAppSettingsClick = context::openAppSettingsPage
onOpenAppSettingsClick = context::openAppSettingsPage,
)
}
}
@@ -0,0 +1,45 @@
/*
* 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.preferences.impl.location
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@Inject
class LocationSettingsPresenter(
private val appPreferencesStore: AppPreferencesStore,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
) : Presenter<LocationSettingsState> {
@Composable
override fun present(): LocationSettingsState {
val liveLocationMinimumDistanceUpdate by produceState(10) {
appPreferencesStore.getLiveLocationMinimumDistanceInMetersUpdateFlow().collect { value = it }
}
fun handleEvent(event: LocationSettingsEvent) {
when (event) {
is LocationSettingsEvent.SetLiveLocationMinimumDistanceUpdate -> sessionCoroutineScope.launch {
appPreferencesStore.setLiveLocationMinimumDistanceInMetersUpdate(event.value)
}
}
}
return LocationSettingsState(
liveLocationMinimumDistanceUpdate = liveLocationMinimumDistanceUpdate,
eventSink = ::handleEvent,
)
}
}
@@ -0,0 +1,13 @@
/*
* 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.preferences.impl.location
data class LocationSettingsState(
val liveLocationMinimumDistanceUpdate: Int,
val eventSink: (LocationSettingsEvent) -> Unit
)
@@ -0,0 +1,26 @@
/*
* 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.preferences.impl.location
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class LocationSettingsStatePreviewParam : PreviewParameterProvider<LocationSettingsState> {
override val values: Sequence<LocationSettingsState>
get() = sequenceOf(
aLocationSettingsState(),
aLocationSettingsState(liveLocationMinimumDistanceUpdate = 80),
)
}
fun aLocationSettingsState(
liveLocationMinimumDistanceUpdate: Int = 50,
eventSink: (LocationSettingsEvent) -> Unit = {},
) = LocationSettingsState(
liveLocationMinimumDistanceUpdate = liveLocationMinimumDistanceUpdate,
eventSink = eventSink
)
@@ -0,0 +1,152 @@
/*
* 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.preferences.impl.location
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.SliderDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.text.stringWithLink
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
import io.element.android.libraries.designsystem.theme.components.ListSupportingText
import io.element.android.libraries.designsystem.theme.components.ListSupportingTextDefaults
import io.element.android.libraries.designsystem.theme.components.Slider
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.ui.strings.CommonStrings
import kotlin.math.roundToInt
@Composable
fun LocationSettingsView(
state: LocationSettingsState,
onBackClick: () -> Unit,
onOpenAppSettingsClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val snackbarDispatcher = LocalSnackbarDispatcher.current
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = snackbarMessage)
PreferencePage(
modifier = modifier,
onBackClick = onBackClick,
title = stringResource(id = CommonStrings.common_location_sharing),
snackbarHost = {
SnackbarHost(
snackbarHostState,
modifier = Modifier.navigationBarsPadding()
)
}
) {
LiveLocationUpdatesSection(
value = state.liveLocationMinimumDistanceUpdate,
onSaveValue = { value ->
state.eventSink(LocationSettingsEvent.SetLiveLocationMinimumDistanceUpdate(value))
},
onOpenAppPermissionsClick = onOpenAppSettingsClick,
)
}
}
@Composable
private fun ColumnScope.LiveLocationUpdatesSection(
value: Int,
onSaveValue: (Int) -> Unit,
onOpenAppPermissionsClick: () -> Unit,
) {
ListSectionHeader(
title = stringResource(R.string.screen_advanced_settings_live_location_section_title),
description = {
ListSupportingText(
text = stringResource(R.string.screen_advanced_settings_live_location_section_description),
contentPadding = ListSupportingTextDefaults.Padding.None,
)
},
hasDivider = false,
)
var sliderValue by remember(value) { mutableIntStateOf(value) }
Column(
modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = pluralStringResource(
R.plurals.screen_advanced_settings_live_location_update_distance,
sliderValue,
sliderValue,
),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
)
val valueRange = 1f..100f
val start = valueRange.start.toInt()
val end = valueRange.endInclusive.toInt()
Row(verticalAlignment = Alignment.CenterVertically) {
Text("${start}m", color = ElementTheme.colors.textSecondary, style = ElementTheme.typography.fontBodyMdRegular)
Slider(
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp),
value = sliderValue.toFloat(),
onValueChange = { sliderValue = it.roundToInt() },
onValueChangeFinish = {
onSaveValue(sliderValue)
},
valueRange = valueRange,
colors = SliderDefaults.colors(
thumbColor = ElementTheme.colors.iconAccentPrimary,
activeTrackColor = ElementTheme.colors.iconAccentPrimary,
inactiveTrackColor = ElementTheme.colors.bgBadgeAccent,
inactiveTickColor = ElementTheme.colors.iconAccentPrimary,
)
)
Text("${end}m", color = ElementTheme.colors.textSecondary, style = ElementTheme.typography.fontBodyMdRegular)
}
}
val footerText = stringWithLink(
textRes = R.string.screen_advanced_settings_live_location_section_footer,
url = "",
linkTextRes = R.string.screen_advanced_settings_live_location_section_footer_link,
onLinkClick = { onOpenAppPermissionsClick() },
)
ListSupportingText(
annotatedString = footerText,
contentPadding = ListSupportingTextDefaults.Padding.Default,
)
}
@Preview
@Composable
internal fun LocationSettingsViewPreview(@PreviewParameter(LocationSettingsStatePreviewParam::class) state: LocationSettingsState) {
LocationSettingsView(
state = state,
onBackClick = { },
onOpenAppSettingsClick = {}
)
}
@@ -0,0 +1,16 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.preferences.impl.media
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
sealed interface MediaSettingsEvent {
data class SetCompressMedia(val compress: Boolean) : MediaSettingsEvent
data class SetVideoUploadQuality(val videoPreset: VideoCompressionPreset) : MediaSettingsEvent
}
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.preferences.impl.media
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 MediaSettingsNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: MediaSettingsPresenter,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
MediaSettingsView(
state = state,
modifier = modifier,
onBackClick = ::navigateUp,
)
}
}
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.preferences.impl.media
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
@Inject
class MediaSettingsPresenter(
private val sessionPreferencesStore: SessionPreferencesStore,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val featureFlagService: FeatureFlagService,
) : Presenter<MediaSettingsState> {
@Composable
override fun present(): MediaSettingsState {
val mediaOptimizationState by produceState<MediaOptimizationState?>(null) {
val hasSplitMediaQualityOptionsFlow = featureFlagService.isFeatureEnabledFlow(FeatureFlags.SelectableMediaQuality)
combine(
hasSplitMediaQualityOptionsFlow,
sessionPreferencesStore.doesOptimizeImages(),
sessionPreferencesStore.getVideoCompressionPreset()
) { hasSplitOptions, compressImages, videoPreset ->
if (hasSplitOptions) {
MediaOptimizationState.Split(
compressImages = compressImages,
videoPreset = videoPreset,
)
} else {
MediaOptimizationState.AllMedia(isEnabled = compressImages)
}
}.collect { value = it }
}
fun handleEvent(event: MediaSettingsEvent) {
when (event) {
is MediaSettingsEvent.SetCompressMedia -> sessionCoroutineScope.launch {
sessionPreferencesStore.setOptimizeImages(event.compress)
}
is MediaSettingsEvent.SetVideoUploadQuality -> sessionCoroutineScope.launch {
sessionPreferencesStore.setVideoCompressionPreset(event.videoPreset)
}
}
}
return MediaSettingsState(
mediaOptimizationState = mediaOptimizationState,
eventSink = ::handleEvent,
)
}
}
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.preferences.impl.media
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
data class MediaSettingsState(
val mediaOptimizationState: MediaOptimizationState?,
val eventSink: (MediaSettingsEvent) -> Unit
)
sealed interface MediaOptimizationState {
data class AllMedia(val isEnabled: Boolean) : MediaOptimizationState
data class Split(
val compressImages: Boolean,
val videoPreset: VideoCompressionPreset,
) : MediaOptimizationState
val shouldCompressImages: Boolean
get() = when (this) {
is AllMedia -> isEnabled
is Split -> compressImages
}
}
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.preferences.impl.media
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
open class MediaSettingsStatePreviewParam : PreviewParameterProvider<MediaSettingsState> {
override val values: Sequence<MediaSettingsState>
get() = sequenceOf(
aMediaSettingsState(),
aMediaSettingsState(mediaOptimizationState = MediaOptimizationState.AllMedia(isEnabled = true)),
aMediaSettingsState(
mediaOptimizationState = MediaOptimizationState.Split(
compressImages = true,
videoPreset = VideoCompressionPreset.HIGH,
)
),
)
}
fun aMediaSettingsState(
mediaOptimizationState: MediaOptimizationState? = MediaOptimizationState.AllMedia(isEnabled = false),
eventSink: (MediaSettingsEvent) -> Unit = {},
) = MediaSettingsState(
mediaOptimizationState = mediaOptimizationState,
eventSink = eventSink
)
@@ -0,0 +1,228 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.preferences.impl.media
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.designsystem.components.dialogs.ListDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.compose.LocalAnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
@Composable
fun MediaSettingsView(
state: MediaSettingsState,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val analyticsService = LocalAnalyticsService.current
val snackbarDispatcher = LocalSnackbarDispatcher.current
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = snackbarMessage)
PreferencePage(
modifier = modifier,
onBackClick = onBackClick,
title = stringResource(id = CommonStrings.common_media_upload_quality),
snackbarHost = {
SnackbarHost(
snackbarHostState,
modifier = Modifier.navigationBarsPadding()
)
}
) {
val compressImages = state.mediaOptimizationState?.shouldCompressImages
when (state.mediaOptimizationState) {
null -> Unit
is MediaOptimizationState.AllMedia -> {
ListItem(
content = {
Text(text = stringResource(id = R.string.screen_advanced_settings_media_compression_title))
},
supportingContent = {
Text(text = stringResource(id = R.string.screen_advanced_settings_media_compression_description))
},
trailingContent = ListItemContent.Switch(
checked = compressImages ?: false,
),
onClick = {
val newValue = !(compressImages ?: false)
analyticsService.captureInteraction(
if (newValue) {
Interaction.Name.MobileSettingsOptimizeMediaUploadsEnabled
} else {
Interaction.Name.MobileSettingsOptimizeMediaUploadsDisabled
}
)
state.eventSink(MediaSettingsEvent.SetCompressMedia(newValue))
}
)
}
is MediaOptimizationState.Split -> {
ListItem(
content = {
Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_image_upload_quality_title))
},
supportingContent = {
Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_image_upload_quality_description))
},
trailingContent = ListItemContent.Switch(
checked = compressImages ?: false,
),
onClick = {
val newValue = !(compressImages ?: false)
analyticsService.captureInteraction(
if (newValue) {
Interaction.Name.MobileSettingsOptimizeMediaUploadsEnabled
} else {
Interaction.Name.MobileSettingsOptimizeMediaUploadsDisabled
}
)
state.eventSink(MediaSettingsEvent.SetCompressMedia(newValue))
}
)
var displaySelectorDialog by remember { mutableStateOf(false) }
ListItem(
content = {
Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_title))
},
supportingContent = {
val description = stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_description)
val quality = when (state.mediaOptimizationState.videoPreset) {
VideoCompressionPreset.LOW -> stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_low)
VideoCompressionPreset.STANDARD -> stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_standard)
VideoCompressionPreset.HIGH -> stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_high)
}
val descriptionWithValue = remember(quality) {
String.format(description, quality)
}
Text(text = descriptionWithValue)
},
onClick = { displaySelectorDialog = true },
)
if (displaySelectorDialog) {
VideoQualitySelectorDialog(
selectedPreset = state.mediaOptimizationState.videoPreset,
onSubmit = { preset ->
state.eventSink(MediaSettingsEvent.SetVideoUploadQuality(preset))
displaySelectorDialog = false
},
onDismiss = { displaySelectorDialog = false },
)
}
}
}
}
}
@Composable
private fun VideoQualitySelectorDialog(
selectedPreset: VideoCompressionPreset,
onSubmit: (VideoCompressionPreset) -> Unit,
onDismiss: () -> Unit
) {
val videoPresets = VideoCompressionPreset.entries
var localSelectedPreset by remember { mutableStateOf(selectedPreset) }
ListDialog(
title = stringResource(CommonStrings.dialog_video_quality_selector_title),
subtitle = stringResource(CommonStrings.dialog_default_video_quality_selector_subtitle),
onSubmit = { onSubmit(localSelectedPreset) },
onDismissRequest = onDismiss,
applyPaddingToContents = false,
) {
for (preset in videoPresets) {
val isSelected = preset == localSelectedPreset
item(
key = preset,
contentType = preset,
) {
val title = when (preset) {
VideoCompressionPreset.LOW -> stringResource(R.string.screen_advanced_settings_optimise_video_upload_quality_low)
VideoCompressionPreset.STANDARD -> stringResource(R.string.screen_advanced_settings_optimise_video_upload_quality_standard)
VideoCompressionPreset.HIGH -> stringResource(R.string.screen_advanced_settings_optimise_video_upload_quality_high)
}
val subtitle = when (preset) {
VideoCompressionPreset.LOW -> stringResource(CommonStrings.common_video_quality_low_description)
VideoCompressionPreset.STANDARD -> stringResource(CommonStrings.common_video_quality_standard_description)
VideoCompressionPreset.HIGH -> stringResource(CommonStrings.common_video_quality_high_description)
}
ListItem(
content = {
Text(
text = title,
style = ElementTheme.typography.fontBodyLgMedium,
)
},
supportingContent = {
Text(
text = subtitle,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
},
leadingContent = ListItemContent.RadioButton(
selected = isSelected,
),
onClick = {
localSelectedPreset = preset
},
)
}
}
}
}
@Composable
@PreviewsDayNight
internal fun MediaSettingsViewPreview(
@PreviewParameter(MediaSettingsStatePreviewParam::class) state: MediaSettingsState,
) {
MediaSettingsView(
state = state,
onBackClick = { },
)
}
@Composable
@PreviewsDayNight
internal fun VideoQualitySelectorDialogPreview() {
ElementPreview {
VideoQualitySelectorDialog(
selectedPreset = VideoCompressionPreset.STANDARD,
onSubmit = { /* no-op */ },
onDismiss = { /* no-op */ }
)
}
}
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.advanced
package io.element.android.features.preferences.impl.moderation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
@@ -0,0 +1,16 @@
/*
* 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.preferences.impl.moderation
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
sealed interface ModerationAndSafetyEvent {
data class SetSharePresenceEnabled(val enabled: Boolean) : ModerationAndSafetyEvent
data class SetHideInviteAvatars(val value: Boolean) : ModerationAndSafetyEvent
data class SetTimelineMediaPreviewValue(val value: MediaPreviewValue) : ModerationAndSafetyEvent
}
@@ -0,0 +1,36 @@
/*
* 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.preferences.impl.moderation
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 ModerationAndSafetyNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: ModerationAndSafetyPresenter,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
ModerationAndSafetyView(
state = state,
modifier = modifier,
onBackClick = ::navigateUp,
)
}
}
@@ -0,0 +1,52 @@
/*
* 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.preferences.impl.moderation
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.libraries.architecture.Presenter
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@Inject
class ModerationAndSafetyPresenter(
private val sessionPreferencesStore: SessionPreferencesStore,
private val mediaPreviewConfigStateStore: MediaPreviewConfigStateStore,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
) : Presenter<ModerationAndSafetyState> {
@Composable
override fun present(): ModerationAndSafetyState {
val isSharePresenceEnabled by remember {
sessionPreferencesStore.isSharePresenceEnabled()
}.collectAsState(initial = true)
val mediaPreviewConfigState = mediaPreviewConfigStateStore.state()
fun handleEvent(event: ModerationAndSafetyEvent) {
when (event) {
is ModerationAndSafetyEvent.SetSharePresenceEnabled -> sessionCoroutineScope.launch {
sessionPreferencesStore.setSharePresence(event.enabled)
}
is ModerationAndSafetyEvent.SetHideInviteAvatars -> mediaPreviewConfigStateStore.setHideInviteAvatars(event.value)
is ModerationAndSafetyEvent.SetTimelineMediaPreviewValue -> mediaPreviewConfigStateStore.setTimelineMediaPreviewValue(event.value)
}
}
return ModerationAndSafetyState(
isSharePresenceEnabled = isSharePresenceEnabled,
mediaPreviewConfigState = mediaPreviewConfigState,
eventSink = ::handleEvent,
)
}
}
@@ -0,0 +1,14 @@
/*
* 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.preferences.impl.moderation
data class ModerationAndSafetyState(
val isSharePresenceEnabled: Boolean,
val mediaPreviewConfigState: MediaPreviewConfigState,
val eventSink: (ModerationAndSafetyEvent) -> Unit
)
@@ -0,0 +1,42 @@
/*
* 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.preferences.impl.moderation
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
open class ModerationAndSafetyStatePreviewParam : PreviewParameterProvider<ModerationAndSafetyState> {
override val values: Sequence<ModerationAndSafetyState>
get() = sequenceOf(
aModerationAndSafetyState(),
aModerationAndSafetyState(isSharePresenceEnabled = true),
aModerationAndSafetyState(hideInviteAvatars = true),
aModerationAndSafetyState(timelineMediaPreviewValue = MediaPreviewValue.Off),
aModerationAndSafetyState(setHideInviteAvatarsAction = AsyncAction.Loading),
aModerationAndSafetyState(setTimelineMediaPreviewAction = AsyncAction.Loading),
)
}
fun aModerationAndSafetyState(
isSharePresenceEnabled: Boolean = false,
hideInviteAvatars: Boolean = false,
timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On,
setTimelineMediaPreviewAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
setHideInviteAvatarsAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (ModerationAndSafetyEvent) -> Unit = {},
) = ModerationAndSafetyState(
isSharePresenceEnabled = isSharePresenceEnabled,
mediaPreviewConfigState = MediaPreviewConfigState(
hideInviteAvatars = hideInviteAvatars,
timelineMediaPreviewValue = timelineMediaPreviewValue,
setTimelineMediaPreviewAction = setTimelineMediaPreviewAction,
setHideInviteAvatarsAction = setHideInviteAvatarsAction
),
eventSink = eventSink
)
@@ -0,0 +1,180 @@
/*
* 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.preferences.impl.moderation
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
import io.element.android.libraries.designsystem.preview.ElementPreviewBlack
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
import io.element.android.libraries.designsystem.theme.components.ListSupportingText
import io.element.android.libraries.designsystem.theme.components.ListSupportingTextDefaults
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.ui.strings.CommonStrings
/**
* Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=606-56426
*/
@Composable
fun ModerationAndSafetyView(
state: ModerationAndSafetyState,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val snackbarDispatcher = LocalSnackbarDispatcher.current
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = snackbarMessage)
PreferencePage(
modifier = modifier,
onBackClick = onBackClick,
title = stringResource(id = CommonStrings.common_moderation_and_safety),
snackbarHost = {
SnackbarHost(
snackbarHostState,
modifier = Modifier.navigationBarsPadding()
)
}
) {
PresenceSection(state)
InvitesSection(state)
MediaSection(state)
}
}
@Composable
private fun PresenceSection(state: ModerationAndSafetyState) {
PreferenceCategory(
title = stringResource(R.string.screen_moderation_and_safety_share_presence_heading),
) {
ListItem(
content = {
Text(text = stringResource(id = R.string.screen_advanced_settings_share_presence))
},
supportingContent = {
Text(text = stringResource(id = R.string.screen_advanced_settings_share_presence_description))
},
trailingContent = ListItemContent.Switch(
checked = state.isSharePresenceEnabled,
),
onClick = { state.eventSink(ModerationAndSafetyEvent.SetSharePresenceEnabled(!state.isSharePresenceEnabled)) }
)
}
}
@Composable
private fun InvitesSection(
state: ModerationAndSafetyState,
modifier: Modifier = Modifier,
) {
PreferenceCategory(
modifier = modifier,
title = stringResource(R.string.screen_moderation_and_safety_invites_heading),
) {
PreferenceSwitch(
title = stringResource(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title),
isChecked = state.mediaPreviewConfigState.hideInviteAvatars,
onCheckedChange = {
state.eventSink(ModerationAndSafetyEvent.SetHideInviteAvatars(it))
},
enabled = !state.mediaPreviewConfigState.setHideInviteAvatarsAction.isLoading()
)
}
}
@Composable
private fun ColumnScope.MediaSection(
state: ModerationAndSafetyState,
) {
ListSectionHeader(
title = stringResource(R.string.screen_advanced_settings_show_media_timeline_title),
hasDivider = true,
description = {
ListSupportingText(
text = stringResource(R.string.screen_advanced_settings_show_media_timeline_subtitle),
contentPadding = ListSupportingTextDefaults.Padding.None,
)
}
)
ListItem(
content = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_show)) },
trailingContent = ListItemContent.RadioButton(
selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.On,
compact = true
),
onClick = {
state.eventSink(ModerationAndSafetyEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.On))
},
enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading()
)
ListItem(
content = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_private_rooms)) },
trailingContent = ListItemContent.RadioButton(
selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.Private,
compact = true
),
onClick = {
state.eventSink(ModerationAndSafetyEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Private))
},
enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading()
)
ListItem(
content = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_hide)) },
trailingContent = ListItemContent.RadioButton(
selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.Off,
compact = true
),
onClick = {
state.eventSink(ModerationAndSafetyEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Off))
},
enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading()
)
}
@PreviewWithLargeHeight
@Composable
internal fun ModerationAndSafetyViewLightPreview(@PreviewParameter(ModerationAndSafetyStatePreviewParam::class) state: ModerationAndSafetyState) =
ElementPreviewLight { ContentToPreview(state) }
@PreviewWithLargeHeight
@Composable
internal fun ModerationAndSafetyViewDarkPreview(@PreviewParameter(ModerationAndSafetyStatePreviewParam::class) state: ModerationAndSafetyState) =
ElementPreviewDark { ContentToPreview(state) }
@PreviewWithLargeHeight
@Composable
internal fun ModerationAndSafetyViewBlackPreview(@PreviewParameter(ModerationAndSafetyStatePreviewParam::class) state: ModerationAndSafetyState) =
ElementPreviewBlack { ContentToPreview(state) }
@ExcludeFromCoverage
@Composable
private fun ContentToPreview(state: ModerationAndSafetyState) {
ModerationAndSafetyView(
state = state,
onBackClick = { }
)
}
@@ -14,4 +14,5 @@ sealed interface PreferencesRootEvent {
data object OnVersionInfoClick : PreferencesRootEvent
data class SwitchToSession(val sessionId: SessionId) : PreferencesRootEvent
data object ToggleOtherAccountsExpanded : PreferencesRootEvent
data class SetTheme(val theme: ThemeOption) : PreferencesRootEvent
}
@@ -8,8 +8,6 @@
package io.element.android.features.preferences.impl.root
import android.app.Activity
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
@@ -18,14 +16,9 @@ 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.compound.theme.ElementTheme
import io.element.android.features.logout.api.direct.DirectLogoutEvent
import io.element.android.features.logout.api.direct.DirectLogoutView
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
import io.element.android.libraries.matrix.api.user.MatrixUser
@ContributesNode(SessionScope::class)
@AssistedInject
@@ -33,77 +26,39 @@ class PreferencesRootNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: PreferencesRootPresenter,
private val directLogoutView: DirectLogoutView,
private val emojiPickerRenderer: EmojiPickerRenderer,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun navigateToAddAccount()
fun navigateToBugReport()
fun navigateToSecureBackup()
fun navigateToAnalyticsSettings()
fun navigateToAbout()
fun navigateToDeveloperSettings()
fun navigateToNotificationSettings()
fun navigateToLockScreenSettings()
fun navigateToAdvancedSettings()
fun navigateToMediaSettings()
fun navigateToLocationSettings()
fun navigateToLabs()
fun navigateToLinkNewDevice()
fun navigateToUserProfile(matrixUser: MatrixUser)
fun navigateToBlockedUsers()
fun startSignOutFlow()
fun startAccountDeactivationFlow()
fun navigateToAccountSettings()
}
private val callback: Callback = callback()
private fun onManageAccountClick(
activity: Activity,
url: String?,
isDark: Boolean,
) {
url?.let {
activity.openUrlInChromeCustomTab(
null,
darkTheme = isDark,
url = it
)
}
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val activity = requireNotNull(LocalActivity.current)
val isDark = ElementTheme.isLightTheme.not()
PreferencesRootView(
state = state,
emojiPickerRenderer = emojiPickerRenderer,
modifier = modifier,
onBackClick = this::navigateUp,
onAddAccountClick = callback::navigateToAddAccount,
onOpenRageShake = callback::navigateToBugReport,
onOpenAnalytics = callback::navigateToAnalyticsSettings,
onOpenAbout = callback::navigateToAbout,
onSecureBackupClick = callback::navigateToSecureBackup,
onOpenDeveloperSettings = callback::navigateToDeveloperSettings,
onOpenAdvancedSettings = callback::navigateToAdvancedSettings,
onOpenMediaSettings = callback::navigateToMediaSettings,
onOpenLocationSettings = callback::navigateToLocationSettings,
onOpenLabs = callback::navigateToLabs,
onLinkNewDeviceClick = callback::navigateToLinkNewDevice,
onManageAccountClick = { onManageAccountClick(activity, it, isDark) },
onOpenNotificationSettings = callback::navigateToNotificationSettings,
onOpenLockScreenSettings = callback::navigateToLockScreenSettings,
onOpenUserProfile = callback::navigateToUserProfile,
onOpenBlockedUsers = callback::navigateToBlockedUsers,
onSignOutClick = {
if (state.directLogoutState.canDoDirectSignOut) {
state.directLogoutState.eventSink(DirectLogoutEvent.Logout(ignoreSdkError = false))
} else {
callback.startSignOutFlow()
}
},
onDeactivateClick = callback::startAccountDeactivationFlow
onOpenAccountSettings = callback::navigateToAccountSettings,
)
directLogoutView.Render(state = state.directLogoutState)
}
}
@@ -10,57 +10,48 @@ package io.element.android.features.preferences.impl.root
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.enterprise.api.SessionEnterpriseService
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.compound.theme.Theme
import io.element.android.compound.theme.mapToTheme
import io.element.android.features.preferences.impl.userstatus.UserStatusState
import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.di.annotations.SessionCoroutineScope
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.matrix.api.MatrixClient
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.verification.SessionVerificationService
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@Inject
class PreferencesRootPresenter(
private val matrixClient: MatrixClient,
private val sessionVerificationService: SessionVerificationService,
private val analyticsService: AnalyticsService,
private val versionFormatter: VersionFormatter,
private val snackbarDispatcher: SnackbarDispatcher,
private val indicatorService: IndicatorService,
private val directLogoutPresenter: Presenter<DirectLogoutState>,
private val showDeveloperSettingsProvider: ShowDeveloperSettingsProvider,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
private val featureFlagService: FeatureFlagService,
private val sessionStore: SessionStore,
private val appPreferencesStore: AppPreferencesStore,
private val sessionEnterpriseService: SessionEnterpriseService,
private val userStatusPresenter: Presenter<UserStatusState>,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
) : Presenter<PreferencesRootState> {
@Composable
override fun present(): PreferencesRootState {
@@ -76,14 +67,16 @@ class PreferencesRootPresenter(
val isOtherAccountsSectionExpanded by remember {
appPreferencesStore.isOtherAccountsExpandedFlow()
}.collectAsState(initial = true)
val showLinkNewDevice by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin)
}.collectAsState(initial = false)
val isUserStatusSupported by produceState(false) {
value = matrixClient.isUserStatusSupported().getOrDefault(false)
}
val userStatusState = if (isUserStatusSupported) userStatusPresenter.present() else null
val isBlackThemeAllowed by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.AllowBlackTheme)
}.collectAsState(initial = false)
val theme = remember(isBlackThemeAllowed) {
appPreferencesStore.getThemeFlow().mapToTheme(isBlackThemeAllowed)
}.collectAsState(initial = Theme.System)
val otherSessions by remember {
sessionStore.sessionsFlow().map { list ->
@@ -100,39 +93,30 @@ class PreferencesRootPresenter(
}
}.collectAsState(initial = persistentListOf())
val themeOption by remember {
derivedStateOf {
when (theme.value) {
Theme.System -> ThemeOption.System
Theme.Dark -> ThemeOption.Dark
Theme.Black -> ThemeOption.Black
Theme.Light -> ThemeOption.Light
}
}
}
val availableThemeOptions = remember(isBlackThemeAllowed) {
if (isBlackThemeAllowed) {
ThemeOption.entries
} else {
ThemeOption.entries.filterNot { it == ThemeOption.Black }
}.toImmutableList()
}
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
val hasAnalyticsProviders = remember { analyticsService.getAvailableAnalyticsProviders().isNotEmpty() }
// We should display the 'complete verification' option if the current session can be verified
val canVerifyUserSession by sessionVerificationService.needsSessionVerification.collectAsState(false)
val showSecureBackupIndicator by indicatorService.showSettingChatBackupIndicator()
val accountManagementUrl: MutableState<String?> = remember {
mutableStateOf(null)
}
var canDeactivateAccount by remember {
mutableStateOf(false)
}
val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false)
LaunchedEffect(Unit) {
canDeactivateAccount = matrixClient.canDeactivateAccount()
}
val nbOfBlockedUsers by produceState(initialValue = 0) {
matrixClient.ignoredUsersFlow
.onEach { value = it.size }
.launchIn(this)
}
val showLabsItem = remember { featureFlagService.getAvailableFeatures(isInLabs = true).isNotEmpty() }
val directLogoutState = directLogoutPresenter.present()
LaunchedEffect(Unit) {
initAccountManagementUrl(accountManagementUrl)
}
val showDeveloperSettings by showDeveloperSettingsProvider.showDeveloperSettings.collectAsState()
fun handleEvent(event: PreferencesRootEvent) {
@@ -146,39 +130,31 @@ class PreferencesRootPresenter(
PreferencesRootEvent.ToggleOtherAccountsExpanded -> coroutineScope.launch {
appPreferencesStore.setOtherAccountsExpanded(!isOtherAccountsSectionExpanded)
}
is PreferencesRootEvent.SetTheme -> sessionCoroutineScope.launch {
when (event.theme) {
ThemeOption.System -> appPreferencesStore.setTheme(Theme.System.name)
ThemeOption.Dark -> appPreferencesStore.setTheme(Theme.Dark.name)
ThemeOption.Black -> appPreferencesStore.setTheme(Theme.Black.name)
ThemeOption.Light -> appPreferencesStore.setTheme(Theme.Light.name)
}
}
}
}
return PreferencesRootState(
myUser = matrixUser.value,
userStatusState = userStatusState,
theme = themeOption,
availableThemeOptions = availableThemeOptions,
version = remember { versionFormatter.get() },
isMultiAccountEnabled = isMultiAccountEnabled,
isOtherAccountsSectionExpanded = isOtherAccountsSectionExpanded,
otherSessions = otherSessions,
showSecureBackup = !canVerifyUserSession,
showSecureBackupBadge = showSecureBackupIndicator,
accountManagementUrl = accountManagementUrl.value,
showAnalyticsSettings = hasAnalyticsProviders,
canReportBug = canReportBug,
showLinkNewDevice = showLinkNewDevice,
showDeveloperSettings = showDeveloperSettings,
canDeactivateAccount = canDeactivateAccount,
nbOfBlockedUsers = nbOfBlockedUsers,
showLabsItem = showLabsItem,
directLogoutState = directLogoutState,
snackbarMessage = snackbarMessage,
eventSink = ::handleEvent,
)
}
private fun CoroutineScope.initAccountManagementUrl(
accountManagementUrl: MutableState<String?>,
) = launch {
accountManagementUrl.value = matrixClient.getAccountManagementUrl(null)
.getOrNull()
?.let {
sessionEnterpriseService.tweakMasUrl(it)
}
}
}
@@ -8,32 +8,54 @@
package io.element.android.features.preferences.impl.root
import io.element.android.features.logout.api.direct.DirectLogoutState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.res.stringResource
import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.userstatus.UserStatusState
import io.element.android.libraries.designsystem.components.preferences.DropdownOption
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
data class PreferencesRootState(
val myUser: MatrixUser,
val theme: ThemeOption,
val availableThemeOptions: ImmutableList<ThemeOption>,
val userStatusState: UserStatusState?,
val version: String,
val isMultiAccountEnabled: Boolean,
val isOtherAccountsSectionExpanded: Boolean,
val otherSessions: ImmutableList<MatrixUser>,
val showSecureBackup: Boolean,
val showSecureBackupBadge: Boolean,
val accountManagementUrl: String?,
val canReportBug: Boolean,
val showLinkNewDevice: Boolean,
val showAnalyticsSettings: Boolean,
val showDeveloperSettings: Boolean,
val canDeactivateAccount: Boolean,
val nbOfBlockedUsers: Int,
val showLabsItem: Boolean,
val directLogoutState: DirectLogoutState,
val snackbarMessage: SnackbarMessage?,
val eventSink: (PreferencesRootEvent) -> Unit,
) {
val showBlockedUsersItem = nbOfBlockedUsers > 0
)
enum class ThemeOption : DropdownOption {
System {
@Composable
@ReadOnlyComposable
override fun getText(): String = stringResource(R.string.theme_system)
},
Light {
@Composable
@ReadOnlyComposable
override fun getText(): String = stringResource(R.string.theme_light)
},
Dark {
@Composable
@ReadOnlyComposable
override fun getText(): String = stringResource(R.string.theme_dark)
},
Black {
@Composable
@ReadOnlyComposable
override fun getText(): String = stringResource(R.string.theme_black)
}
}
@@ -9,8 +9,6 @@
package io.element.android.features.preferences.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.features.preferences.impl.userstatus.UserStatusPickerState
import io.element.android.features.preferences.impl.userstatus.UserStatusState
import io.element.android.features.preferences.impl.userstatus.aUserStatusState
@@ -30,40 +28,25 @@ open class PreferencesRootStatePreviewParam : PreviewParameterProvider<Preferenc
version = "Version 1.1 (1)",
isMultiAccountEnabled = true,
otherSessions = aMatrixUserList().drop(1).take(1),
showSecureBackup = true,
accountManagementUrl = "aUrl",
canReportBug = true,
showLinkNewDevice = true,
showAnalyticsSettings = true,
canDeactivateAccount = false,
nbOfBlockedUsers = 3,
showLabsItem = true,
),
aPreferencesRootState(
myUser = aMatrixUser(displayName = null),
isMultiAccountEnabled = true,
showSecureBackup = true,
canDeactivateAccount = true,
),
aPreferencesRootState(
isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = true,
otherSessions = aMatrixUserList().drop(1).take(3),
accountManagementUrl = "aUrl",
showSecureBackup = true,
showSecureBackupBadge = true,
),
aPreferencesRootState(
showLabsItem = true,
canReportBug = true,
nbOfBlockedUsers = 3,
snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete),
),
aPreferencesRootState(
showLinkNewDevice = true,
showAnalyticsSettings = true,
showDeveloperSettings = true,
canDeactivateAccount = true,
),
aPreferencesRootState(userStatusState = aUserStatusState(pickerState = UserStatusPickerState.ShowingPicker)),
// Minimal state
@@ -76,18 +59,12 @@ fun aPreferencesRootState(
version: String = "Version 1.1 (1)",
isMultiAccountEnabled: Boolean = false,
isOtherAccountsSectionExpanded: Boolean = false,
theme: ThemeOption = ThemeOption.System,
availableThemeOptions: List<ThemeOption> = ThemeOption.entries,
otherSessions: List<MatrixUser> = emptyList(),
showSecureBackup: Boolean = false,
showSecureBackupBadge: Boolean = false,
accountManagementUrl: String? = null,
canReportBug: Boolean = false,
showLinkNewDevice: Boolean = false,
showAnalyticsSettings: Boolean = false,
showDeveloperSettings: Boolean = false,
canDeactivateAccount: Boolean = false,
nbOfBlockedUsers: Int = 0,
showLabsItem: Boolean = false,
directLogoutState: DirectLogoutState = aDirectLogoutState(),
userStatusState: UserStatusState? = aUserStatusState(),
snackbarMessage: SnackbarMessage? = null,
eventSink: (PreferencesRootEvent) -> Unit = {},
@@ -95,19 +72,13 @@ fun aPreferencesRootState(
myUser = myUser,
version = version,
isMultiAccountEnabled = isMultiAccountEnabled,
theme = theme,
availableThemeOptions = availableThemeOptions.toImmutableList(),
isOtherAccountsSectionExpanded = isOtherAccountsSectionExpanded,
otherSessions = otherSessions.toImmutableList(),
showSecureBackup = showSecureBackup,
showSecureBackupBadge = showSecureBackupBadge,
accountManagementUrl = accountManagementUrl,
canReportBug = canReportBug,
showLinkNewDevice = showLinkNewDevice,
showAnalyticsSettings = showAnalyticsSettings,
showDeveloperSettings = showDeveloperSettings,
canDeactivateAccount = canDeactivateAccount,
nbOfBlockedUsers = nbOfBlockedUsers,
showLabsItem = showLabsItem,
directLogoutState = directLogoutState,
userStatusState = userStatusState,
snackbarMessage = snackbarMessage,
eventSink = eventSink,
@@ -48,6 +48,8 @@ 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.AvatarType
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@@ -56,14 +58,12 @@ import io.element.android.libraries.designsystem.theme.components.HorizontalDivi
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.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer
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.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings
@@ -75,21 +75,14 @@ fun PreferencesRootView(
emojiPickerRenderer: EmojiPickerRenderer,
onBackClick: () -> Unit,
onAddAccountClick: () -> Unit,
onSecureBackupClick: () -> Unit,
onManageAccountClick: (url: String) -> Unit,
onLinkNewDeviceClick: () -> Unit,
onOpenAnalytics: () -> Unit,
onOpenRageShake: () -> Unit,
onOpenLockScreenSettings: () -> Unit,
onOpenAbout: () -> Unit,
onOpenDeveloperSettings: () -> Unit,
onOpenAdvancedSettings: () -> Unit,
onOpenMediaSettings: () -> Unit,
onOpenLocationSettings: () -> Unit,
onOpenLabs: () -> Unit,
onOpenNotificationSettings: () -> Unit,
onOpenUserProfile: (MatrixUser) -> Unit,
onOpenBlockedUsers: () -> Unit,
onSignOutClick: () -> Unit,
onDeactivateClick: () -> Unit,
onOpenAccountSettings: () -> Unit,
modifier: Modifier = Modifier,
) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
@@ -103,7 +96,7 @@ fun PreferencesRootView(
) {
UserPreferences(
modifier = Modifier.clickable {
onOpenUserProfile(state.myUser)
onOpenAccountSettings()
},
matrixUser = state.myUser,
)
@@ -123,32 +116,20 @@ fun PreferencesRootView(
thickness = 8.dp,
color = ElementTheme.colors.bgSubtleSecondary,
)
// 'Account' section
ManageAccountSection(
// 'App settings' section
AppSettingsSection(
state = state,
onManageAccountClick = onManageAccountClick,
onLinkNewDeviceClick = onLinkNewDeviceClick,
onOpenBlockedUsers = onOpenBlockedUsers
)
// 'Manage my app' section
ManageAppSection(
state = state,
onOpenNotificationSettings = onOpenNotificationSettings,
onOpenLockScreenSettings = onOpenLockScreenSettings,
onSecureBackupClick = onSecureBackupClick,
onOpenMediaSettings = onOpenMediaSettings,
onOpenLocationSettings = onOpenLocationSettings,
)
// General section
GeneralSection(
state = state,
onOpenAbout = onOpenAbout,
onOpenAnalytics = onOpenAnalytics,
onOpenRageShake = onOpenRageShake,
onOpenAdvancedSettings = onOpenAdvancedSettings,
onOpenDeveloperSettings = onOpenDeveloperSettings,
onOpenLabs = onOpenLabs,
onSignOutClick = onSignOutClick,
onDeactivateClick = onDeactivateClick,
)
// Version
Footer(
@@ -171,8 +152,8 @@ private fun BoxScope.UserStatusUpdateIndicator(updateStatusAction: AsyncAction<U
AsyncActionIndicator(
asyncAction = updateStatusAction,
modifier = Modifier
.align(Alignment.TopCenter)
.statusBarsPadding(),
.align(Alignment.TopCenter)
.statusBarsPadding(),
loading = { AsyncIndicator.Loading(text = stringResource(CommonStrings.common_saving)) },
failure = { _ -> AsyncIndicator.Failure(text = stringResource(CommonStrings.common_failed)) },
)
@@ -267,10 +248,10 @@ private fun ColumnScope.MultiAccountSection(
state.otherSessions.forEach { matrixUser ->
MatrixUserRow(
modifier = Modifier
.clickable {
state.eventSink(PreferencesRootEvent.SwitchToSession(matrixUser.userId))
}
.padding(top = 2.dp, bottom = 2.dp, end = 8.dp),
.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,
@@ -294,66 +275,42 @@ private fun AddAccountItem(onAddAccountClick: () -> Unit) {
}
@Composable
private fun ColumnScope.ManageAppSection(
private fun AppSettingsSection(
state: PreferencesRootState,
onOpenNotificationSettings: () -> Unit,
onOpenLockScreenSettings: () -> Unit,
onSecureBackupClick: () -> Unit,
onOpenLocationSettings: () -> Unit,
onOpenMediaSettings: () -> Unit,
) {
ListItem(
content = { Text(stringResource(id = R.string.screen_notification_settings_title)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Notifications())),
onClick = onOpenNotificationSettings,
)
ListItem(
content = { Text(stringResource(id = CommonStrings.common_screen_lock)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())),
onClick = onOpenLockScreenSettings,
)
if (state.showSecureBackup) {
ListItem(
content = { Text(stringResource(id = CommonStrings.common_encryption)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Key())),
trailingContent = ListItemContent.Badge.takeIf { state.showSecureBackupBadge },
onClick = onSecureBackupClick,
PreferenceCategory(
title = stringResource(CommonStrings.common_app_settings),
showTopDivider = false,
) {
PreferenceDropdown(
icon = CompoundIcons.DarkMode(),
dropDownIcon = CompoundIcons.ChevronUpDown(),
title = stringResource(id = CommonStrings.common_appearance),
selectedOption = state.theme,
options = state.availableThemeOptions,
onSelectOption = { themeOption ->
state.eventSink(PreferencesRootEvent.SetTheme(themeOption))
}
)
}
HorizontalDivider()
}
@Composable
private fun ColumnScope.ManageAccountSection(
state: PreferencesRootState,
onManageAccountClick: (url: String) -> Unit,
onLinkNewDeviceClick: () -> Unit,
onOpenBlockedUsers: () -> Unit,
) {
state.accountManagementUrl?.let { url ->
ListItem(
content = { Text(stringResource(id = CommonStrings.action_manage_account_and_devices)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())),
trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut())),
onClick = { onManageAccountClick(url) },
content = { Text(stringResource(id = CommonStrings.common_media_upload_quality)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Image())),
onClick = onOpenMediaSettings,
)
}
if (state.showLinkNewDevice) {
ListItem(
content = { Text(stringResource(id = CommonStrings.common_link_new_device)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Devices())),
onClick = onLinkNewDeviceClick,
content = { Text(stringResource(id = CommonStrings.common_screen_lock)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())),
onClick = onOpenLockScreenSettings,
)
}
if (state.showBlockedUsersItem) {
ListItem(
content = { Text(stringResource(id = CommonStrings.common_blocked_users)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())),
onClick = onOpenBlockedUsers,
trailingContent = ListItemContent.Text(state.nbOfBlockedUsers.toString()),
content = { Text(stringResource(id = CommonStrings.common_location_sharing)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.LocationPin())),
onClick = onOpenLocationSettings,
)
}
if (state.accountManagementUrl != null || state.showLinkNewDevice || state.showBlockedUsersItem) {
HorizontalDivider()
}
}
@Composable
@@ -361,18 +318,20 @@ private fun ColumnScope.GeneralSection(
state: PreferencesRootState,
onOpenAbout: () -> Unit,
onOpenAnalytics: () -> Unit,
onOpenRageShake: () -> Unit,
onOpenAdvancedSettings: () -> Unit,
onOpenLabs: () -> Unit,
onOpenDeveloperSettings: () -> Unit,
onSignOutClick: () -> Unit,
onDeactivateClick: () -> Unit,
) {
ListItem(
content = { Text(stringResource(id = CommonStrings.common_advanced_settings)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Settings())),
onClick = onOpenAdvancedSettings,
HorizontalDivider(
thickness = 1.dp,
color = ElementTheme.colors.bgSubtleSecondary,
)
if (state.showAnalyticsSettings) {
ListItem(
content = { Text(stringResource(id = CommonStrings.common_analytics)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Chart())),
onClick = onOpenAnalytics,
)
}
if (state.showLabsItem) {
ListItem(
content = { Text(stringResource(id = R.string.screen_labs_title)) },
@@ -385,35 +344,6 @@ private fun ColumnScope.GeneralSection(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Info())),
onClick = onOpenAbout,
)
if (state.canReportBug) {
ListItem(
content = { Text(stringResource(id = CommonStrings.common_report_a_problem)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChatProblem())),
onClick = onOpenRageShake
)
}
if (state.showAnalyticsSettings) {
ListItem(
content = { Text(stringResource(id = CommonStrings.common_analytics)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Chart())),
onClick = onOpenAnalytics,
)
}
HorizontalDivider()
ListItem(
content = { Text(stringResource(id = CommonStrings.action_signout)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Close())),
style = ListItemStyle.Destructive,
onClick = onSignOutClick,
)
if (state.canDeactivateAccount) {
ListItem(
content = { Text(stringResource(id = CommonStrings.action_delete_account)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Delete())),
style = ListItemStyle.Destructive,
onClick = onDeactivateClick,
)
}
// Put developer settings at the end, so nothing bad happens if the user clicks 8 times to enable the entry
AnimatedVisibility(
visible = state.showDeveloperSettings,
@@ -429,9 +359,9 @@ private fun ColumnScope.Footer(
) {
Text(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.clickable(enabled = onClick != null, onClick = onClick ?: {})
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 24.dp),
.align(Alignment.CenterHorizontally)
.clickable(enabled = onClick != null, onClick = onClick ?: {})
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 24.dp),
textAlign = TextAlign.Center,
text = version,
style = ElementTheme.typography.fontBodySmRegular,
@@ -471,19 +401,12 @@ private fun ContentToPreview(state: PreferencesRootState) {
onBackClick = {},
onAddAccountClick = {},
onOpenAnalytics = {},
onOpenRageShake = {},
onOpenDeveloperSettings = {},
onOpenAdvancedSettings = {},
onOpenLocationSettings = {},
onOpenMediaSettings = {},
onOpenLabs = {},
onOpenAbout = {},
onSecureBackupClick = {},
onManageAccountClick = {},
onLinkNewDeviceClick = {},
onOpenNotificationSettings = {},
onOpenLockScreenSettings = {},
onOpenUserProfile = {},
onOpenBlockedUsers = {},
onSignOutClick = {},
onDeactivateClick = {},
onOpenAccountSettings = {},
)
}
@@ -37,8 +37,8 @@
<string name="screen_advanced_settings_share_presence_description">"If turned off, you won’t be able to send or receive read receipts or typing notifications."</string>
<string name="screen_advanced_settings_show_media_timeline_always_hide">"Always hide"</string>
<string name="screen_advanced_settings_show_media_timeline_always_show">"Always show"</string>
<string name="screen_advanced_settings_show_media_timeline_private_rooms">"In private rooms"</string>
<string name="screen_advanced_settings_show_media_timeline_subtitle">"A hidden media can always be shown by tapping on it"</string>
<string name="screen_advanced_settings_show_media_timeline_private_rooms">"Show in private rooms"</string>
<string name="screen_advanced_settings_show_media_timeline_subtitle">"Hidden media can always be shown by tapping on it."</string>
<string name="screen_advanced_settings_show_media_timeline_title">"Show media in timeline"</string>
<string name="screen_advanced_settings_view_source_description">"Enable option to view message source in the timeline."</string>
<string name="screen_blocked_users_empty">"You have no blocked users"</string>
@@ -61,6 +61,8 @@
<string name="screen_labs_header_description">"Try out our latest ideas in development. These features are not finalised; they may be unstable, may change."</string>
<string name="screen_labs_header_title">"Feeling experimental?"</string>
<string name="screen_labs_title">"Labs"</string>
<string name="screen_moderation_and_safety_invites_heading">"Invites"</string>
<string name="screen_moderation_and_safety_share_presence_heading">"Your appearance"</string>
<string name="screen_notification_settings_additional_settings_section_title">"Additional settings"</string>
<string name="screen_notification_settings_call_ringtone_label">"Call ringtone"</string>
<string name="screen_notification_settings_calls_label">"Audio and video calls"</string>
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.advanced
package io.element.android.features.preferences.impl.media
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
@@ -71,11 +71,11 @@ class AdvancedSettingsPresenterTest {
with(awaitItem()) {
assertThat(isDeveloperModeEnabled).isFalse()
eventSink(AdvancedSettingsEvent.SetDeveloperModeEnabled(true))
eventSink(MediaSettingsEvent.SetDeveloperModeEnabled(true))
}
with(awaitItem()) {
assertThat(isDeveloperModeEnabled).isTrue()
eventSink(AdvancedSettingsEvent.SetDeveloperModeEnabled(false))
eventSink(MediaSettingsEvent.SetDeveloperModeEnabled(false))
}
with(awaitItem()) {
assertThat(isDeveloperModeEnabled).isFalse()
@@ -94,11 +94,11 @@ class AdvancedSettingsPresenterTest {
with(awaitItem()) {
assertThat(isSharePresenceEnabled).isTrue()
eventSink(AdvancedSettingsEvent.SetSharePresenceEnabled(false))
eventSink(MediaSettingsEvent.SetSharePresenceEnabled(false))
}
with(awaitItem()) {
assertThat(isSharePresenceEnabled).isFalse()
eventSink(AdvancedSettingsEvent.SetSharePresenceEnabled(true))
eventSink(MediaSettingsEvent.SetSharePresenceEnabled(true))
}
with(awaitItem()) {
assertThat(isSharePresenceEnabled).isTrue()
@@ -117,11 +117,11 @@ class AdvancedSettingsPresenterTest {
with(awaitItem()) {
assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isTrue()
eventSink(AdvancedSettingsEvent.SetCompressMedia(false))
eventSink(MediaSettingsEvent.SetCompressMedia(false))
}
with(awaitItem()) {
assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isFalse()
eventSink(AdvancedSettingsEvent.SetCompressMedia(true))
eventSink(MediaSettingsEvent.SetCompressMedia(true))
}
with(awaitItem()) {
assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isTrue()
@@ -144,11 +144,11 @@ class AdvancedSettingsPresenterTest {
with(awaitItem()) {
assertThat((mediaOptimizationState as MediaOptimizationState.Split).compressImages).isTrue()
eventSink(AdvancedSettingsEvent.SetCompressImages(false))
eventSink(MediaSettingsEvent.SetCompressImages(false))
}
with(awaitItem()) {
assertThat((mediaOptimizationState as MediaOptimizationState.Split).compressImages).isFalse()
eventSink(AdvancedSettingsEvent.SetCompressImages(true))
eventSink(MediaSettingsEvent.SetCompressImages(true))
}
with(awaitItem()) {
assertThat((mediaOptimizationState as MediaOptimizationState.Split).compressImages).isTrue()
@@ -171,11 +171,11 @@ class AdvancedSettingsPresenterTest {
with(awaitItem()) {
assertThat((mediaOptimizationState as MediaOptimizationState.Split).videoPreset).isEqualTo(VideoCompressionPreset.STANDARD)
eventSink(AdvancedSettingsEvent.SetVideoUploadQuality(VideoCompressionPreset.LOW))
eventSink(MediaSettingsEvent.SetVideoUploadQuality(VideoCompressionPreset.LOW))
}
with(awaitItem()) {
assertThat((mediaOptimizationState as MediaOptimizationState.Split).videoPreset).isEqualTo(VideoCompressionPreset.LOW)
eventSink(AdvancedSettingsEvent.SetVideoUploadQuality(VideoCompressionPreset.HIGH))
eventSink(MediaSettingsEvent.SetVideoUploadQuality(VideoCompressionPreset.HIGH))
}
with(awaitItem()) {
assertThat((mediaOptimizationState as MediaOptimizationState.Split).videoPreset).isEqualTo(VideoCompressionPreset.HIGH)
@@ -194,15 +194,15 @@ class AdvancedSettingsPresenterTest {
with(awaitItem()) {
assertThat(theme).isEqualTo(ThemeOption.System)
eventSink(AdvancedSettingsEvent.SetTheme(ThemeOption.Dark))
eventSink(MediaSettingsEvent.SetTheme(ThemeOption.Dark))
}
with(awaitItem()) {
assertThat(theme).isEqualTo(ThemeOption.Dark)
eventSink(AdvancedSettingsEvent.SetTheme(ThemeOption.Light))
eventSink(MediaSettingsEvent.SetTheme(ThemeOption.Light))
}
with(awaitItem()) {
assertThat(theme).isEqualTo(ThemeOption.Light)
eventSink(AdvancedSettingsEvent.SetTheme(ThemeOption.System))
eventSink(MediaSettingsEvent.SetTheme(ThemeOption.System))
}
with(awaitItem()) {
assertThat(theme).isEqualTo(ThemeOption.System)
@@ -242,7 +242,7 @@ class AdvancedSettingsPresenterTest {
with(awaitItem()) {
assertThat(liveLocationMinimumDistanceUpdate).isEqualTo(10)
eventSink(AdvancedSettingsEvent.SetLiveLocationMinimumDistanceUpdate(42))
eventSink(MediaSettingsEvent.SetLiveLocationMinimumDistanceUpdate(42))
}
with(awaitItem()) {
assertThat(liveLocationMinimumDistanceUpdate).isEqualTo(42)
@@ -298,11 +298,11 @@ class AdvancedSettingsPresenterTest {
with(awaitItem()) {
assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse()
eventSink(AdvancedSettingsEvent.SetHideInviteAvatars(true))
eventSink(MediaSettingsEvent.SetHideInviteAvatars(true))
}
with(awaitItem()) {
assertThat(mediaPreviewConfigState.hideInviteAvatars).isTrue()
eventSink(AdvancedSettingsEvent.SetHideInviteAvatars(false))
eventSink(MediaSettingsEvent.SetHideInviteAvatars(false))
}
with(awaitItem()) {
assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse()
@@ -323,11 +323,11 @@ class AdvancedSettingsPresenterTest {
with(awaitItem()) {
assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On)
eventSink(AdvancedSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Off))
eventSink(MediaSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Off))
}
with(awaitItem()) {
assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Off)
eventSink(AdvancedSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Private))
eventSink(MediaSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Private))
}
with(awaitItem()) {
assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private)
@@ -383,7 +383,7 @@ class AdvancedSettingsPresenterTest {
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
mediaPreviewConfigStateStore: MediaPreviewConfigStateStore = FakeMediaPreviewConfigStateStore(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
) = AdvancedSettingsPresenter(
) = MediaSettingsPresenter(
appPreferencesStore = appPreferencesStore,
sessionPreferencesStore = sessionPreferencesStore,
mediaPreviewConfigStateStore = mediaPreviewConfigStateStore,
@@ -8,7 +8,7 @@
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.preferences.impl.advanced
package io.element.android.features.preferences.impl.media
import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider
@@ -39,10 +39,10 @@ import org.robolectric.annotation.Config
class AdvancedSettingsViewTest : RobolectricTest() {
@Test
fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvent>(expectEvents = false)
val eventsRecorder = EventsRecorder<MediaSettingsEvent>(expectEvents = false)
ensureCalledOnce {
setAdvancedSettingsView(
state = aAdvancedSettingsState(
state = aMediaSettingsState(
eventSink = eventsRecorder
),
onBackClick = it
@@ -53,21 +53,21 @@ class AdvancedSettingsViewTest : RobolectricTest() {
@Test
fun `clicking on other theme emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvent>()
val eventsRecorder = EventsRecorder<MediaSettingsEvent>()
setAdvancedSettingsView(
state = aAdvancedSettingsState(
state = aMediaSettingsState(
eventSink = eventsRecorder,
),
)
clickOn(CommonStrings.common_appearance)
clickOn(R.string.theme_dark)
eventsRecorder.assertSingle(AdvancedSettingsEvent.SetTheme(ThemeOption.Dark))
eventsRecorder.assertSingle(MediaSettingsEvent.SetTheme(ThemeOption.Dark))
}
@Test
fun `black theme is shown when available`() = runAndroidComposeUiTest {
setAdvancedSettingsView(
state = aAdvancedSettingsState(
state = aMediaSettingsState(
availableThemeOptions = ThemeOption.entries.toImmutableList(),
),
)
@@ -81,7 +81,7 @@ class AdvancedSettingsViewTest : RobolectricTest() {
@Test
fun `black theme is hidden when unavailable`() = runAndroidComposeUiTest {
setAdvancedSettingsView(
state = aAdvancedSettingsState(
state = aMediaSettingsState(
availableThemeOptions = ThemeOption.entries.filterNot { it == ThemeOption.Black }.toImmutableList(),
),
)
@@ -91,40 +91,40 @@ class AdvancedSettingsViewTest : RobolectricTest() {
@Test
fun `clicking on View source emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvent>()
val eventsRecorder = EventsRecorder<MediaSettingsEvent>()
setAdvancedSettingsView(
state = aAdvancedSettingsState(
state = aMediaSettingsState(
eventSink = eventsRecorder,
),
)
clickOn(CommonStrings.action_view_source)
eventsRecorder.assertSingle(AdvancedSettingsEvent.SetDeveloperModeEnabled(true))
eventsRecorder.assertSingle(MediaSettingsEvent.SetDeveloperModeEnabled(true))
}
@Test
fun `clicking on Share presence emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvent>()
val eventsRecorder = EventsRecorder<MediaSettingsEvent>()
setAdvancedSettingsView(
state = aAdvancedSettingsState(
state = aMediaSettingsState(
eventSink = eventsRecorder,
),
)
clickOn(R.string.screen_advanced_settings_share_presence)
eventsRecorder.assertSingle(AdvancedSettingsEvent.SetSharePresenceEnabled(true))
eventsRecorder.assertSingle(MediaSettingsEvent.SetSharePresenceEnabled(true))
}
@Test
fun `clicking on media to enable compression emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvent>()
val eventsRecorder = EventsRecorder<MediaSettingsEvent>()
val analyticsService = FakeAnalyticsService()
setAdvancedSettingsView(
state = aAdvancedSettingsState(
state = aMediaSettingsState(
eventSink = eventsRecorder,
),
analyticsService = analyticsService
)
clickOn(R.string.screen_advanced_settings_media_compression_description)
eventsRecorder.assertSingle(AdvancedSettingsEvent.SetCompressMedia(true))
eventsRecorder.assertSingle(MediaSettingsEvent.SetCompressMedia(true))
assertThat(analyticsService.capturedEvents).isEqualTo(
listOf(
Interaction(
@@ -136,17 +136,17 @@ class AdvancedSettingsViewTest : RobolectricTest() {
@Test
fun `clicking on media to disable compression emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvent>()
val eventsRecorder = EventsRecorder<MediaSettingsEvent>()
val analyticsService = FakeAnalyticsService()
setAdvancedSettingsView(
state = aAdvancedSettingsState(
state = aMediaSettingsState(
mediaOptimizationState = MediaOptimizationState.AllMedia(isEnabled = true),
eventSink = eventsRecorder,
),
analyticsService = analyticsService
)
clickOn(R.string.screen_advanced_settings_media_compression_description)
eventsRecorder.assertSingle(AdvancedSettingsEvent.SetCompressMedia(false))
eventsRecorder.assertSingle(MediaSettingsEvent.SetCompressMedia(false))
assertThat(analyticsService.capturedEvents).isEqualTo(
listOf(
Interaction(
@@ -159,65 +159,65 @@ class AdvancedSettingsViewTest : RobolectricTest() {
@Test
@Config(qualifiers = "h1080dp")
fun `clicking on hide invite avatars emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvent>()
val eventsRecorder = EventsRecorder<MediaSettingsEvent>()
setAdvancedSettingsView(
state = aAdvancedSettingsState(
state = aMediaSettingsState(
eventSink = eventsRecorder,
hideInviteAvatars = false
),
)
clickOn(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title)
eventsRecorder.assertSingle(AdvancedSettingsEvent.SetHideInviteAvatars(true))
eventsRecorder.assertSingle(MediaSettingsEvent.SetHideInviteAvatars(true))
}
@Test
@Config(qualifiers = "h1080dp")
fun `clicking on timeline media preview always hide emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvent>()
val eventsRecorder = EventsRecorder<MediaSettingsEvent>()
setAdvancedSettingsView(
state = aAdvancedSettingsState(
state = aMediaSettingsState(
eventSink = eventsRecorder,
timelineMediaPreviewValue = MediaPreviewValue.On
),
)
clickOn(R.string.screen_advanced_settings_show_media_timeline_always_hide)
eventsRecorder.assertSingle(AdvancedSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Off))
eventsRecorder.assertSingle(MediaSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Off))
}
@Test
@Config(qualifiers = "h1080dp")
fun `clicking on timeline media preview private rooms emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvent>()
val eventsRecorder = EventsRecorder<MediaSettingsEvent>()
setAdvancedSettingsView(
state = aAdvancedSettingsState(
state = aMediaSettingsState(
eventSink = eventsRecorder,
timelineMediaPreviewValue = MediaPreviewValue.On
),
)
clickOn(R.string.screen_advanced_settings_show_media_timeline_private_rooms)
eventsRecorder.assertSingle(AdvancedSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Private))
eventsRecorder.assertSingle(MediaSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Private))
}
@Test
@Config(qualifiers = "h1080dp")
fun `clicking on timeline media preview always show emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvent>()
val eventsRecorder = EventsRecorder<MediaSettingsEvent>()
setAdvancedSettingsView(
state = aAdvancedSettingsState(
state = aMediaSettingsState(
eventSink = eventsRecorder,
timelineMediaPreviewValue = MediaPreviewValue.Off
),
)
clickOn(R.string.screen_advanced_settings_show_media_timeline_always_show)
eventsRecorder.assertSingle(AdvancedSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.On))
eventsRecorder.assertSingle(MediaSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.On))
}
@Test
@Config(qualifiers = "h1080dp")
fun `hide invite avatars toggle is disabled when action is loading`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvent>(expectEvents = false)
val eventsRecorder = EventsRecorder<MediaSettingsEvent>(expectEvents = false)
setAdvancedSettingsView(
state = aAdvancedSettingsState(
state = aMediaSettingsState(
eventSink = eventsRecorder,
hideInviteAvatars = false,
setHideInviteAvatarsAction = AsyncAction.Loading
@@ -230,9 +230,9 @@ class AdvancedSettingsViewTest : RobolectricTest() {
@Test
@Config(qualifiers = "h1080dp")
fun `timeline media preview options are disabled when action is loading`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvent>(expectEvents = false)
val eventsRecorder = EventsRecorder<MediaSettingsEvent>(expectEvents = false)
setAdvancedSettingsView(
state = aAdvancedSettingsState(
state = aMediaSettingsState(
eventSink = eventsRecorder,
timelineMediaPreviewValue = MediaPreviewValue.On,
setTimelineMediaPreviewAction = AsyncAction.Loading
@@ -245,7 +245,7 @@ class AdvancedSettingsViewTest : RobolectricTest() {
}
private fun AndroidComposeUiTest<ComponentActivity>.setAdvancedSettingsView(
state: AdvancedSettingsState,
state: MediaSettingsState,
analyticsService: AnalyticsService = FakeAnalyticsService(),
onBackClick: () -> Unit = EnsureNeverCalled(),
onOpenAppSettings: () -> Unit = EnsureNeverCalled(),
@@ -254,7 +254,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setAdvancedSettingsView(
CompositionLocalProvider(
LocalAnalyticsService provides analyticsService,
) {
AdvancedSettingsView(
MediaSettingsView(
state = state,
onBackClick = onBackClick,
onOpenAppSettingsClick = onOpenAppSettings
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.advanced
package io.element.android.features.preferences.impl.media
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.advanced
package io.element.android.features.preferences.impl.media
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
@@ -544,7 +544,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setView(
onOpenLockScreenSettings = onOpenLockScreenSettings,
onOpenAbout = onOpenAbout,
onOpenDeveloperSettings = onOpenDeveloperSettings,
onOpenAdvancedSettings = onOpenAdvancedSettings,
onOpenMediaSettings = onOpenAdvancedSettings,
onOpenLabs = onOpenLabs,
onOpenNotificationSettings = onOpenNotificationSettings,
onOpenUserProfile = onOpenUserProfile,
@@ -24,8 +24,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.atoms.CopiableTextAtom
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewAliasAtom
import io.element.android.libraries.designsystem.atomic.organisms.RoomPreviewOrganism
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@@ -121,7 +121,7 @@ private fun RoomAliasResolverContent(
PlaceholderAtom(width = AvatarSize.RoomPreviewHeader.dp, height = AvatarSize.RoomPreviewHeader.dp)
},
title = {
RoomPreviewAliasAtom(roomAlias.value)
CopiableTextAtom(roomAlias.value)
},
subtitle = {
if (isLoading) {