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