Simplify the copy of the history visibility settings

Closes #5898
This commit is contained in:
Benoit Marty
2025-12-18 11:00:10 +01:00
committed by Benoit Marty
parent 98f43f2402
commit d04ebe880b
10 changed files with 142 additions and 71 deletions

View File

@@ -10,18 +10,13 @@ package io.element.android.features.messages.impl.crypto.historyvisible
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.appconfig.LearnMoreConfig
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertLevel
import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.stringWithLink
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
@@ -33,37 +28,16 @@ fun HistoryVisibleStateView(
if (!state.showAlert) {
return
}
ComposerAlertMolecule(
modifier = modifier,
avatar = null,
showIcon = true,
level = ComposerAlertLevel.Info,
content = buildAnnotatedString {
val learnMoreStr = stringResource(CommonStrings.action_learn_more)
val fullText = stringResource(CommonStrings.crypto_history_visible, learnMoreStr)
append(fullText)
val learnMoreStartIndex = fullText.lastIndexOf(learnMoreStr)
addStyle(
style = SpanStyle(
textDecoration = TextDecoration.Underline,
fontWeight = FontWeight.Bold,
color = ElementTheme.colors.textPrimary
),
start = learnMoreStartIndex,
end = learnMoreStartIndex + learnMoreStr.length,
)
addLink(
url = LinkAnnotation.Url(
content = stringWithLink(
textRes = CommonStrings.crypto_history_visible,
url = LearnMoreConfig.HISTORY_VISIBLE_URL,
linkInteractionListener = {
onLinkClick(LearnMoreConfig.HISTORY_VISIBLE_URL, true)
}
onLinkClick = { url -> onLinkClick(url, true) },
),
start = learnMoreStartIndex,
end = learnMoreStartIndex + learnMoreStr.length,
)
},
submitText = stringResource(CommonStrings.action_dismiss),
onSubmitClick = { state.eventSink(HistoryVisibleEvent.Acknowledge) },
)

View File

@@ -28,7 +28,9 @@ setupDependencyInjection()
dependencies {
api(projects.features.securityandprivacy.api)
implementation(projects.appconfig)
implementation(projects.appnav)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.designsystem)

View File

@@ -8,6 +8,8 @@
package io.element.android.features.securityandprivacy.impl.root
import android.app.Activity
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -19,7 +21,9 @@ import com.bumble.appyx.core.plugin.plugins
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.securityandprivacy.impl.SecurityAndPrivacyNavigator
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.architecture.appyx.launchMolecule
import io.element.android.libraries.di.RoomScope
@@ -35,11 +39,20 @@ class SecurityAndPrivacyNode(
private val stateFlow = launchMolecule { presenter.present() }
private fun onOpenExternalUrl(activity: Activity, darkTheme: Boolean, url: String) {
activity.openUrlInChromeCustomTab(null, darkTheme, url)
}
@Composable
override fun View(modifier: Modifier) {
val activity = requireNotNull(LocalActivity.current)
val isDark = ElementTheme.isLightTheme.not()
val state by stateFlow.collectAsState()
SecurityAndPrivacyView(
state = state,
onLinkClick = { url ->
onOpenExternalUrl(activity, isDark, url)
},
modifier = modifier
)
}

View File

@@ -301,21 +301,21 @@ private fun SecurityAndPrivacyRoomAccess.map(): JoinRule? {
private fun RoomHistoryVisibility?.map(): SecurityAndPrivacyHistoryVisibility {
return when (this) {
RoomHistoryVisibility.WorldReadable -> SecurityAndPrivacyHistoryVisibility.Anyone
RoomHistoryVisibility.Joined,
RoomHistoryVisibility.Invited -> SecurityAndPrivacyHistoryVisibility.SinceInvite
RoomHistoryVisibility.Shared -> SecurityAndPrivacyHistoryVisibility.SinceSelection
// All other cases are not supported so we default to SinceSelection
RoomHistoryVisibility.Invited -> SecurityAndPrivacyHistoryVisibility.Invited
RoomHistoryVisibility.Shared -> SecurityAndPrivacyHistoryVisibility.Shared
RoomHistoryVisibility.WorldReadable -> SecurityAndPrivacyHistoryVisibility.WorldReadable
// All other cases are not supported so we default to Shared
is RoomHistoryVisibility.Custom,
null -> SecurityAndPrivacyHistoryVisibility.SinceSelection
null -> SecurityAndPrivacyHistoryVisibility.Shared
}
}
private fun SecurityAndPrivacyHistoryVisibility.map(): RoomHistoryVisibility {
return when (this) {
SecurityAndPrivacyHistoryVisibility.SinceSelection -> RoomHistoryVisibility.Shared
SecurityAndPrivacyHistoryVisibility.SinceInvite -> RoomHistoryVisibility.Invited
SecurityAndPrivacyHistoryVisibility.Anyone -> RoomHistoryVisibility.WorldReadable
SecurityAndPrivacyHistoryVisibility.Invited -> RoomHistoryVisibility.Invited
SecurityAndPrivacyHistoryVisibility.Shared -> RoomHistoryVisibility.Shared
SecurityAndPrivacyHistoryVisibility.WorldReadable -> RoomHistoryVisibility.WorldReadable
}
}

View File

@@ -11,7 +11,7 @@ package io.element.android.features.securityandprivacy.impl.root
import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.collections.immutable.toImmutableList
data class SecurityAndPrivacyState(
// the settings that are currently applied on the room.
@@ -23,19 +23,24 @@ data class SecurityAndPrivacyState(
val isKnockEnabled: Boolean,
val saveAction: AsyncAction<Unit>,
val isSpace: Boolean,
private val permissions: SecurityAndPrivacyPermissions,
val eventSink: (SecurityAndPrivacyEvent) -> Unit
) {
val canBeSaved = savedSettings != editedSettings
val availableHistoryVisibilities = buildSet {
add(SecurityAndPrivacyHistoryVisibility.SinceSelection)
// Logic is in https://github.com/element-hq/element-meta/issues/3029
val availableHistoryVisibilities = buildList {
// Shared is always available
add(SecurityAndPrivacyHistoryVisibility.Shared)
if (editedSettings.roomAccess == SecurityAndPrivacyRoomAccess.Anyone && !editedSettings.isEncrypted) {
add(SecurityAndPrivacyHistoryVisibility.Anyone)
add(SecurityAndPrivacyHistoryVisibility.WorldReadable)
} else {
add(SecurityAndPrivacyHistoryVisibility.SinceInvite)
add(SecurityAndPrivacyHistoryVisibility.Invited)
}
}.toImmutableSet()
}
.sorted()
.toImmutableList()
val showRoomAccessSection = permissions.canChangeRoomAccess
@@ -55,18 +60,19 @@ data class SecurityAndPrivacySettings(
)
enum class SecurityAndPrivacyHistoryVisibility {
SinceSelection,
SinceInvite,
Anyone;
// Order matters, and is from the most to the least restrictive
Invited,
Shared,
WorldReadable;
/**
* Returns the fallback visibility when the current visibility is not available.
*/
fun fallback(): SecurityAndPrivacyHistoryVisibility {
return when (this) {
SinceSelection,
SinceInvite -> SinceSelection
Anyone -> SinceInvite
Invited,
Shared -> Shared
WorldReadable -> Invited
}
}
}

View File

@@ -93,7 +93,7 @@ fun aSecurityAndPrivacySettings(
roomAccess: SecurityAndPrivacyRoomAccess = SecurityAndPrivacyRoomAccess.InviteOnly,
isEncrypted: Boolean = true,
address: String? = null,
historyVisibility: SecurityAndPrivacyHistoryVisibility = SecurityAndPrivacyHistoryVisibility.SinceSelection,
historyVisibility: SecurityAndPrivacyHistoryVisibility = SecurityAndPrivacyHistoryVisibility.Shared,
isVisibleInRoomDirectory: AsyncData<Boolean> = AsyncData.Uninitialized,
) = SecurityAndPrivacySettings(
roomAccess = roomAccess,

View File

@@ -27,8 +27,10 @@ import androidx.compose.material3.ListItemDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.appconfig.LearnMoreConfig
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.securityandprivacy.impl.R
@@ -44,6 +46,7 @@ import io.element.android.libraries.designsystem.components.list.ListItemContent
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.text.stringWithLink
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
@@ -52,11 +55,12 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.ImmutableList
@Composable
fun SecurityAndPrivacyView(
state: SecurityAndPrivacyState,
onLinkClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
BackHandler {
@@ -122,6 +126,7 @@ fun SecurityAndPrivacyView(
savedOptions = state.savedSettings.historyVisibility,
availableOptions = state.availableHistoryVisibilities,
onSelectOption = { state.eventSink(SecurityAndPrivacyEvent.ChangeHistoryVisibility(it)) },
onLinkClick = onLinkClick,
)
}
}
@@ -176,6 +181,7 @@ private fun SecurityAndPrivacyToolbar(
private fun SecurityAndPrivacySection(
title: String,
modifier: Modifier = Modifier,
subtitle: AnnotatedString? = null,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
@@ -187,6 +193,15 @@ private fun SecurityAndPrivacySection(
color = ElementTheme.colors.textPrimary,
modifier = Modifier.padding(horizontal = 16.dp),
)
if (subtitle != null) {
Spacer(Modifier.height(8.dp))
Text(
text = subtitle,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
content()
}
}
@@ -359,12 +374,18 @@ private fun EncryptionSection(
private fun HistoryVisibilitySection(
editedOption: SecurityAndPrivacyHistoryVisibility?,
savedOptions: SecurityAndPrivacyHistoryVisibility?,
availableOptions: ImmutableSet<SecurityAndPrivacyHistoryVisibility>,
availableOptions: ImmutableList<SecurityAndPrivacyHistoryVisibility>,
onSelectOption: (SecurityAndPrivacyHistoryVisibility) -> Unit,
onLinkClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
SecurityAndPrivacySection(
title = stringResource(R.string.screen_security_and_privacy_room_history_section_header),
subtitle = stringWithLink(
textRes = R.string.screen_security_and_privacy_room_history_section_footer,
url = LearnMoreConfig.HISTORY_VISIBLE_URL,
onLinkClick = onLinkClick,
),
modifier = modifier,
) {
for (availableOption in availableOptions) {
@@ -396,9 +417,9 @@ private fun HistoryVisibilityItem(
isEnabled: Boolean = true,
) {
val headlineText = when (option) {
SecurityAndPrivacyHistoryVisibility.SinceSelection -> stringResource(R.string.screen_security_and_privacy_room_history_since_selecting_option_title)
SecurityAndPrivacyHistoryVisibility.SinceInvite -> stringResource(R.string.screen_security_and_privacy_room_history_since_invite_option_title)
SecurityAndPrivacyHistoryVisibility.Anyone -> stringResource(R.string.screen_security_and_privacy_room_history_anyone_option_title)
SecurityAndPrivacyHistoryVisibility.Invited -> stringResource(R.string.screen_security_and_privacy_room_history_since_invite_option_title)
SecurityAndPrivacyHistoryVisibility.Shared -> stringResource(R.string.screen_security_and_privacy_room_history_since_selecting_option_title)
SecurityAndPrivacyHistoryVisibility.WorldReadable -> stringResource(R.string.screen_security_and_privacy_room_history_anyone_option_title)
}
ListItem(
headlineContent = { Text(text = headlineText) },
@@ -424,5 +445,6 @@ internal fun SecurityAndPrivacyViewDarkPreview(@PreviewParameter(SecurityAndPriv
private fun ContentToPreview(state: SecurityAndPrivacyState) {
SecurityAndPrivacyView(
state = state,
onLinkClick = {},
)
}

View File

@@ -84,7 +84,7 @@ class SecurityAndPrivacyPresenterTest {
with(awaitItem()) {
assertThat(editedSettings).isEqualTo(savedSettings)
assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.Anyone)
assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.Anyone)
assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.Shared)
assertThat(editedSettings.address).isEqualTo(A_ROOM_ALIAS.value)
assertThat(canBeSaved).isFalse()
}
@@ -122,16 +122,16 @@ class SecurityAndPrivacyPresenterTest {
presenter.test {
skipItems(1)
with(awaitItem()) {
assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.SinceSelection)
eventSink(SecurityAndPrivacyEvent.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.SinceInvite))
assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.Shared)
eventSink(SecurityAndPrivacyEvent.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.Invited))
}
with(awaitItem()) {
assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.SinceInvite)
assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.Invited)
assertThat(canBeSaved).isTrue()
eventSink(SecurityAndPrivacyEvent.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.SinceSelection))
eventSink(SecurityAndPrivacyEvent.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.WorldReadable))
}
with(awaitItem()) {
assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.SinceSelection)
assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.WorldReadable)
assertThat(canBeSaved).isFalse()
}
}
@@ -250,10 +250,10 @@ class SecurityAndPrivacyPresenterTest {
eventSink(SecurityAndPrivacyEvent.ChangeRoomAccess(SecurityAndPrivacyRoomAccess.Anyone))
}
with(awaitItem()) {
eventSink(SecurityAndPrivacyEvent.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.Anyone))
eventSink(SecurityAndPrivacyEvent.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.WorldReadable))
}
with(awaitItem()) {
assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.Anyone)
assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.WorldReadable)
eventSink(SecurityAndPrivacyEvent.ConfirmEnableEncryption)
}
skipItems(1)
@@ -318,10 +318,10 @@ class SecurityAndPrivacyPresenterTest {
eventSink(SecurityAndPrivacyEvent.ChangeRoomAccess(SecurityAndPrivacyRoomAccess.Anyone))
}
with(awaitItem()) {
eventSink(SecurityAndPrivacyEvent.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.Anyone))
eventSink(SecurityAndPrivacyEvent.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.WorldReadable))
}
with(awaitItem()) {
assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.Anyone)
assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.WorldReadable)
eventSink(SecurityAndPrivacyEvent.ConfirmEnableEncryption)
}
skipItems(1)

View File

@@ -24,6 +24,7 @@ import io.element.android.features.securityandprivacy.impl.root.aSecurityAndPriv
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.pressBack
@@ -146,12 +147,12 @@ class SecurityAndPrivacyViewTest {
val state = aSecurityAndPrivacyState(
eventSink = recorder,
editedSettings = aSecurityAndPrivacySettings(
historyVisibility = SecurityAndPrivacyHistoryVisibility.SinceSelection,
historyVisibility = SecurityAndPrivacyHistoryVisibility.Invited,
),
)
rule.setSecurityAndPrivacyView(state)
rule.clickOn(R.string.screen_security_and_privacy_room_history_since_selecting_option_title)
recorder.assertSingle(SecurityAndPrivacyEvent.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.SinceSelection))
rule.clickOn(R.string.screen_security_and_privacy_room_history_since_invite_option_title)
recorder.assertSingle(SecurityAndPrivacyEvent.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.Invited))
}
@Test
@@ -184,10 +185,12 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setSecur
state: SecurityAndPrivacyState = aSecurityAndPrivacyState(
eventSink = EventsRecorder(expectEvents = false),
),
onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(),
) {
setContent {
SecurityAndPrivacyView(
state = state,
onLinkClick = onLinkClick,
)
}
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright (c) 2025 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.designsystem.text
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun stringWithLink(
@StringRes textRes: Int,
url: String,
onLinkClick: (String) -> Unit,
@StringRes linkTextRes: Int = CommonStrings.action_learn_more,
) = buildAnnotatedString {
val learnMoreStr = stringResource(linkTextRes)
val fullText = stringResource(textRes, learnMoreStr)
append(fullText)
val learnMoreStartIndex = fullText.lastIndexOf(learnMoreStr)
addStyle(
style = SpanStyle(
textDecoration = TextDecoration.Underline,
fontWeight = FontWeight.Bold,
color = ElementTheme.colors.textPrimary
),
start = learnMoreStartIndex,
end = learnMoreStartIndex + learnMoreStr.length,
)
addLink(
url = LinkAnnotation.Url(
url = url,
linkInteractionListener = {
onLinkClick(url)
}
),
start = learnMoreStartIndex,
end = learnMoreStartIndex + learnMoreStr.length,
)
}