1
0
mirror of https://github.com/element-hq/element-x-android.git synced 2026-10-23 18:49:00 +03:00

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,8 +35,8 @@ import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
@Composable
fun RoomPreviewAliasAtom(
alias: String,
fun CopiableTextAtom(
text: String,
modifier: Modifier = Modifier,
copiable: Boolean = true
) {
@@ -46,7 +46,7 @@ fun RoomPreviewAliasAtom(
modifier = modifier
.clickable(enabled = copiable) {
coroutineScope.launch {
val clipData = ClipData.newPlainText(alias, alias)
val clipData = ClipData.newPlainText(text, text)
clipboard.setClipEntry(clipData.toClipEntry())
}
},
@@ -55,7 +55,7 @@ fun RoomPreviewAliasAtom(
) {
Text(
modifier = Modifier.weight(weight = 1f, fill = false),
text = alias,
text = text,
style = ElementTheme.typography.fontBodyLgRegular,
textAlign = TextAlign.Center,
maxLines = 1,
@@ -75,9 +75,9 @@ fun RoomPreviewAliasAtom(
@PreviewsDayNight
@Composable
internal fun RoomPreviewAliasAtomPreview() = ElementPreview {
RoomPreviewAliasAtom(
alias = "#room-alias:matrix.org",
internal fun CopiableTextAtomPreview() = ElementPreview {
CopiableTextAtom(
text = "#room-alias:matrix.org",
copiable = true
)
}

View File

@@ -55,6 +55,7 @@ fun <T : DropdownOption> PreferenceDropdown(
supportingText: String? = null,
enabled: Boolean = true,
icon: ImageVector? = null,
dropDownIcon: ImageVector = CompoundIcons.ChevronDown(),
@DrawableRes iconResourceId: Int? = null,
showIconAreaIfNoIcon: Boolean = false,
) {
@@ -84,13 +85,14 @@ fun <T : DropdownOption> PreferenceDropdown(
trailingContent = ListItemContent.Custom(
content = { enabled ->
DropdownTrailingContent(
dropDownIcon = dropDownIcon,
selectedOption = selectedOption,
options = options,
onSelectOption = onSelectOption,
expanded = isDropdownExpanded,
onExpandedChange = { isDropdownExpanded = it },
enabled = enabled,
modifier = Modifier.fillMaxSize(0.3f)
modifier = Modifier.fillMaxSize(0.3f),
)
}
),
@@ -119,6 +121,7 @@ private fun <T : DropdownOption> DropdownTrailingContent(
onSelectOption: (T) -> Unit,
enabled: Boolean,
modifier: Modifier = Modifier,
dropDownIcon: ImageVector = CompoundIcons.ChevronDown(),
) {
Row(
modifier = modifier,
@@ -135,7 +138,7 @@ private fun <T : DropdownOption> DropdownTrailingContent(
modifier = Modifier.weight(1f),
)
Icon(
imageVector = CompoundIcons.ChevronDown(),
imageVector = dropDownIcon,
contentDescription = null,
tint = enabled.toIconSecondaryEnabledColor(),
)

View File

@@ -20,7 +20,7 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.datasource.LoremIpsum
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewAliasAtom
import io.element.android.libraries.designsystem.atomic.atoms.CopiableTextAtom
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.organisms.RoomPreviewOrganism
@@ -87,7 +87,7 @@ fun SpaceHeaderView(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (alias != null) {
RoomPreviewAliasAtom(alias = alias.value)
CopiableTextAtom(text = alias.value)
}
SpaceInfoRow(visibility = visibility)
}

View File

@@ -0,0 +1,92 @@
/*
* 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.libraries.matrix.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.atomic.atoms.CopiableTextAtom
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun UserHeaderView(
matrixUser: MatrixUser,
onAvatarClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Avatar(
avatarData = matrixUser.getAvatarData(AvatarSize.UserHeader),
avatarType = AvatarType.User,
contentDescription = stringResource(CommonStrings.a11y_user_avatar),
modifier = Modifier
.clip(CircleShape)
.clickable(
enabled = matrixUser.avatarUrl != null,
onClickLabel = stringResource(CommonStrings.action_view),
) {
onAvatarClick(matrixUser.avatarUrl!!)
}
)
Spacer(modifier = Modifier.height(16.dp))
matrixUser.displayName?.let { displayName ->
Text(
modifier = Modifier
.clipToBounds()
.semantics {
heading()
},
text = displayName,
style = ElementTheme.typography.fontHeadingMdRegular,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(16.dp))
}
CopiableTextAtom(
text = matrixUser.userId.value,
)
Spacer(modifier = Modifier.height(12.dp))
}
}
@PreviewsDayNight
@Composable
internal fun UserHeaderViewPreview() = ElementPreview {
UserHeaderView(
aMatrixUser(),
onAvatarClick = {},
)
}

View File

@@ -223,6 +223,7 @@
<string name="banner_migrate_to_native_sliding_sync_title">"Upgrade available"</string>
<string name="common_about">"About"</string>
<string name="common_acceptable_use_policy">"Acceptable use policy"</string>
<string name="common_account_settings">"Account settings"</string>
<string name="common_add_account">"Add an account"</string>
<string name="common_add_another_account">"Add account"</string>
<string name="common_adding_caption">"Adding caption"</string>
@@ -231,6 +232,7 @@
<string name="common_analytics">"Analytics"</string>
<string name="common_android_shortcuts_remove_reason_left_room">"You left the room"</string>
<string name="common_android_shortcuts_remove_reason_session_logged_out">"You were logged out of the session"</string>
<string name="common_app_settings">"App settings"</string>
<string name="common_appearance">"Appearance"</string>
<string name="common_audio">"Audio"</string>
<string name="common_beta">"Beta"</string>
@@ -306,10 +308,12 @@ Reason: %1$s."</string>
<string name="common_live_location_ended">"Live location ended"</string>
<string name="common_loading">"Loading…"</string>
<string name="common_loading_more">"Loading more…"</string>
<string name="common_location_sharing">"Location sharing"</string>
<plurals name="common_many_members">
<item quantity="one">"%d other"</item>
<item quantity="other">"%d others"</item>
</plurals>
<string name="common_media_upload_quality">"Media upload quality"</string>
<plurals name="common_member_count">
<item quantity="one">"%1$d Member"</item>
<item quantity="other">"%1$d Members"</item>
@@ -319,6 +323,7 @@ Reason: %1$s."</string>
<string name="common_message_failed_to_send">"Message failed to send"</string>
<string name="common_message_layout">"Message layout"</string>
<string name="common_message_removed">"Message removed"</string>
<string name="common_moderation_and_safety">"Moderation &amp; safety"</string>
<string name="common_modern">"Modern"</string>
<string name="common_mute">"Mute"</string>
<string name="common_name">"Name"</string>
@@ -419,6 +424,7 @@ Reason: %1$s."</string>
<string name="common_success">"Success"</string>
<string name="common_suggested">"Suggested"</string>
<string name="common_suggestions">"Suggestions"</string>
<string name="common_switch_accounts">"Switch accounts"</string>
<string name="common_syncing">"Syncing"</string>
<string name="common_text">"Text"</string>
<string name="common_third_party_notices">"Third-party notices"</string>
@@ -458,6 +464,7 @@ Reason: %1$s."</string>
<string name="common_waiting_live_location">"Waiting for live location…"</string>
<string name="common_world_readable_history">"Anyone can see history"</string>
<string name="common_you">"You"</string>
<string name="common_your_account">"Your account"</string>
<string name="content_scanner_not_found">"Download not available."</string>
<string name="content_scanner_not_found_title">"Something went wrong"</string>
<string name="content_scanner_scanning">"Scanning…"</string>
@@ -552,12 +559,6 @@ Are you sure you want to continue?"</string>
<string name="screen_resolve_send_failure_you_unsigned_device_subtitle">"One or more of your devices are unverified. You can send the message anyway, or you can cancel for now and try again later after you have verified all of your devices."</string>
<string name="screen_resolve_send_failure_you_unsigned_device_title">"Your message was not sent because you have not verified one or more of your devices"</string>
<string name="screen_room_change_role_administrators_or_owners_title">"Edit Admins or Owners"</string>
<string name="screen_room_confirm_removal_message">"Are you sure you wish to remove this message?"</string>
<string name="screen_room_confirm_removal_reason_label">"Reason (optional)"</string>
<string name="screen_room_confirm_removal_reason_placeholder">"Enter…"</string>
<string name="screen_room_confirm_removal_reason_placeholder_android">"Reason…"</string>
<string name="screen_room_confirm_removal_reason_supporting_text">"(Optional)"</string>
<string name="screen_room_confirm_removal_title">"Confirm removal"</string>
<string name="screen_room_error_failed_processing_media">"Failed processing media to upload, please try again."</string>
<string name="screen_room_error_failed_retrieving_user_details">"Could not retrieve user details"</string>
<string name="screen_room_event_pill">"Message in %1$s"</string>

View File

@@ -337,6 +337,7 @@
"screen_edit_profile_.*",
"screen_notification_settings_.*",
"screen\\.notification_settings\\..*",
"screen\\.moderation_and_safety\\..*",
"screen_blocked_users_.*",
"full_screen_intent_banner_.*",
"troubleshoot_notifications_entry_point_.*",