change (preferences) : move from dev settings to advanced settings and add new safety values

This commit is contained in:
ganfra 2025-04-08 20:27:47 +02:00
parent 3e90b3c26d
commit 119d09c494
19 changed files with 166 additions and 82 deletions

View file

@ -16,6 +16,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toImmutableSet
import javax.inject.Inject import javax.inject.Inject
@ -25,14 +26,14 @@ class TimelineProtectionPresenter @Inject constructor(
) : Presenter<TimelineProtectionState> { ) : Presenter<TimelineProtectionState> {
@Composable @Composable
override fun present(): TimelineProtectionState { override fun present(): TimelineProtectionState {
val hideMediaContent by appPreferencesStore.doesHideImagesAndVideosFlow().collectAsState(initial = false) val mediaPreviewValue = appPreferencesStore.getTimelineMediaPreviewValueFlow().collectAsState(initial = MediaPreviewValue.Off)
var allowedEvents by remember { mutableStateOf<Set<EventId>>(setOf()) } var allowedEvents by remember { mutableStateOf<Set<EventId>>(setOf()) }
val protectionState by remember(hideMediaContent) { val protectionState by remember {
derivedStateOf { derivedStateOf {
if (hideMediaContent) { if (mediaPreviewValue.value == MediaPreviewValue.On) {
ProtectionState.RenderOnly(eventIds = allowedEvents.toImmutableSet())
} else {
ProtectionState.RenderAll ProtectionState.RenderAll
} else {
ProtectionState.RenderOnly(eventIds = allowedEvents.toImmutableSet())
} }
} }
} }

View file

@ -8,6 +8,7 @@
package io.element.android.features.preferences.impl.advanced package io.element.android.features.preferences.impl.advanced
import io.element.android.compound.theme.Theme import io.element.android.compound.theme.Theme
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
sealed interface AdvancedSettingsEvents { sealed interface AdvancedSettingsEvents {
data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvents data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvents
@ -16,4 +17,6 @@ sealed interface AdvancedSettingsEvents {
data object ChangeTheme : AdvancedSettingsEvents data object ChangeTheme : AdvancedSettingsEvents
data object CancelChangeTheme : AdvancedSettingsEvents data object CancelChangeTheme : AdvancedSettingsEvents
data class SetTheme(val theme: Theme) : AdvancedSettingsEvents data class SetTheme(val theme: Theme) : AdvancedSettingsEvents
data class SetTimelineMediaPreviewValue(val value: MediaPreviewValue) : AdvancedSettingsEvents
data class SetHideInviteAvatars(val value: Boolean) : AdvancedSettingsEvents
} }

View file

@ -17,6 +17,7 @@ import androidx.compose.runtime.setValue
import io.element.android.compound.theme.Theme import io.element.android.compound.theme.Theme
import io.element.android.compound.theme.mapToTheme import io.element.android.compound.theme.mapToTheme
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -44,6 +45,14 @@ class AdvancedSettingsPresenter @Inject constructor(
.collectAsState(initial = Theme.System) .collectAsState(initial = Theme.System)
var showChangeThemeDialog by remember { mutableStateOf(false) } var showChangeThemeDialog by remember { mutableStateOf(false) }
val hideInviteAvatars by remember {
appPreferencesStore.getHideInviteAvatarsFlow()
}.collectAsState(false)
val timelineMediaPreviewValue by remember {
appPreferencesStore.getTimelineMediaPreviewValueFlow()
}.collectAsState(initial = MediaPreviewValue.On)
fun handleEvents(event: AdvancedSettingsEvents) { fun handleEvents(event: AdvancedSettingsEvents) {
when (event) { when (event) {
is AdvancedSettingsEvents.SetDeveloperModeEnabled -> localCoroutineScope.launch { is AdvancedSettingsEvents.SetDeveloperModeEnabled -> localCoroutineScope.launch {
@ -61,6 +70,12 @@ class AdvancedSettingsPresenter @Inject constructor(
appPreferencesStore.setTheme(event.theme.name) appPreferencesStore.setTheme(event.theme.name)
showChangeThemeDialog = false showChangeThemeDialog = false
} }
is AdvancedSettingsEvents.SetHideInviteAvatars -> localCoroutineScope.launch {
appPreferencesStore.setHideInviteAvatars(event.value)
}
is AdvancedSettingsEvents.SetTimelineMediaPreviewValue -> localCoroutineScope.launch {
appPreferencesStore.setTimelineMediaPreviewValue(event.value)
}
} }
} }
@ -70,6 +85,8 @@ class AdvancedSettingsPresenter @Inject constructor(
doesCompressMedia = doesCompressMedia, doesCompressMedia = doesCompressMedia,
theme = theme, theme = theme,
showChangeThemeDialog = showChangeThemeDialog, showChangeThemeDialog = showChangeThemeDialog,
hideInviteAvatars = hideInviteAvatars,
timelineMediaPreviewValue = timelineMediaPreviewValue,
eventSink = { handleEvents(it) } eventSink = { handleEvents(it) }
) )
} }

View file

@ -8,6 +8,7 @@
package io.element.android.features.preferences.impl.advanced package io.element.android.features.preferences.impl.advanced
import io.element.android.compound.theme.Theme import io.element.android.compound.theme.Theme
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
data class AdvancedSettingsState( data class AdvancedSettingsState(
val isDeveloperModeEnabled: Boolean, val isDeveloperModeEnabled: Boolean,
@ -15,5 +16,7 @@ data class AdvancedSettingsState(
val doesCompressMedia: Boolean, val doesCompressMedia: Boolean,
val theme: Theme, val theme: Theme,
val showChangeThemeDialog: Boolean, val showChangeThemeDialog: Boolean,
val hideInviteAvatars: Boolean,
val timelineMediaPreviewValue: MediaPreviewValue,
val eventSink: (AdvancedSettingsEvents) -> Unit val eventSink: (AdvancedSettingsEvents) -> Unit
) )

View file

@ -9,6 +9,7 @@ package io.element.android.features.preferences.impl.advanced
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.compound.theme.Theme import io.element.android.compound.theme.Theme
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSettingsState> { open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSettingsState> {
override val values: Sequence<AdvancedSettingsState> override val values: Sequence<AdvancedSettingsState>
@ -18,6 +19,8 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSett
aAdvancedSettingsState(showChangeThemeDialog = true), aAdvancedSettingsState(showChangeThemeDialog = true),
aAdvancedSettingsState(isSharePresenceEnabled = true), aAdvancedSettingsState(isSharePresenceEnabled = true),
aAdvancedSettingsState(doesCompressMedia = true), aAdvancedSettingsState(doesCompressMedia = true),
aAdvancedSettingsState(hideInviteAvatars = true),
aAdvancedSettingsState(timelineMediaPreviewValue = MediaPreviewValue.Off)
) )
} }
@ -26,6 +29,8 @@ fun aAdvancedSettingsState(
isSharePresenceEnabled: Boolean = false, isSharePresenceEnabled: Boolean = false,
doesCompressMedia: Boolean = false, doesCompressMedia: Boolean = false,
showChangeThemeDialog: Boolean = false, showChangeThemeDialog: Boolean = false,
hideInviteAvatars: Boolean = false,
timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On,
eventSink: (AdvancedSettingsEvents) -> Unit = {}, eventSink: (AdvancedSettingsEvents) -> Unit = {},
) = AdvancedSettingsState( ) = AdvancedSettingsState(
isDeveloperModeEnabled = isDeveloperModeEnabled, isDeveloperModeEnabled = isDeveloperModeEnabled,
@ -33,5 +38,7 @@ fun aAdvancedSettingsState(
doesCompressMedia = doesCompressMedia, doesCompressMedia = doesCompressMedia,
theme = Theme.System, theme = Theme.System,
showChangeThemeDialog = showChangeThemeDialog, showChangeThemeDialog = showChangeThemeDialog,
hideInviteAvatars = hideInviteAvatars,
timelineMediaPreviewValue = timelineMediaPreviewValue,
eventSink = eventSink eventSink = eventSink
) )

View file

@ -7,6 +7,7 @@
package io.element.android.features.preferences.impl.advanced package io.element.android.features.preferences.impl.advanced
import android.preference.PreferenceCategory
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -18,11 +19,18 @@ import io.element.android.features.preferences.impl.R
import io.element.android.libraries.designsystem.components.dialogs.ListOption import io.element.android.libraries.designsystem.components.dialogs.ListOption
import io.element.android.libraries.designsystem.components.dialogs.SingleSelectionDialog import io.element.android.libraries.designsystem.components.dialogs.SingleSelectionDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceDivider
import io.element.android.libraries.designsystem.components.preferences.PreferencePage 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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.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.theme.components.Text
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.compose.LocalAnalyticsService import io.element.android.services.analytics.compose.LocalAnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
@ -98,6 +106,7 @@ fun AdvancedSettingsView(
state.eventSink(AdvancedSettingsEvents.SetCompressMedia(newValue)) state.eventSink(AdvancedSettingsEvents.SetCompressMedia(newValue))
} }
) )
ModerationAndSafety(state)
} }
if (state.showChangeThemeDialog) { if (state.showChangeThemeDialog) {
@ -116,6 +125,57 @@ fun AdvancedSettingsView(
} }
} }
@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.hideInviteAvatars,
onCheckedChange = {
state.eventSink(AdvancedSettingsEvents.SetHideInviteAvatars(it))
},
)
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(
headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_hide)) },
leadingContent = ListItemContent.RadioButton(selected = state.timelineMediaPreviewValue == MediaPreviewValue.Off, compact = true),
onClick = {
state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off))
},
)
ListItem(
headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_private_rooms)) },
leadingContent = ListItemContent.RadioButton(selected = state.timelineMediaPreviewValue == MediaPreviewValue.Private, compact = true),
onClick = {
state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Private))
},
)
ListItem(
headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_show)) },
leadingContent = ListItemContent.RadioButton(selected = state.timelineMediaPreviewValue == MediaPreviewValue.On, compact = true),
onClick = {
state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.On))
},
)
}
}
@Composable @Composable
private fun getOptions(): ImmutableList<ListOption> { private fun getOptions(): ImmutableList<ListOption> {
return themes.map { return themes.map {

View file

@ -14,7 +14,6 @@ import io.element.android.libraries.matrix.api.tracing.TraceLogPack
sealed interface DeveloperSettingsEvents { sealed interface DeveloperSettingsEvents {
data class UpdateEnabledFeature(val feature: FeatureUiModel, val isEnabled: Boolean) : DeveloperSettingsEvents data class UpdateEnabledFeature(val feature: FeatureUiModel, val isEnabled: Boolean) : DeveloperSettingsEvents
data class SetCustomElementCallBaseUrl(val baseUrl: String?) : DeveloperSettingsEvents data class SetCustomElementCallBaseUrl(val baseUrl: String?) : DeveloperSettingsEvents
data class SetHideImagesAndVideos(val value: Boolean) : DeveloperSettingsEvents
data class SetTracingLogLevel(val logLevel: LogLevelItem) : DeveloperSettingsEvents data class SetTracingLogLevel(val logLevel: LogLevelItem) : DeveloperSettingsEvents
data class ToggleTracingLogPack(val logPack: TraceLogPack, val enabled: Boolean) : DeveloperSettingsEvents data class ToggleTracingLogPack(val logPack: TraceLogPack, val enabled: Boolean) : DeveloperSettingsEvents
data object ClearCache : DeveloperSettingsEvents data object ClearCache : DeveloperSettingsEvents

View file

@ -74,9 +74,6 @@ class DeveloperSettingsPresenter @Inject constructor(
val customElementCallBaseUrl by appPreferencesStore val customElementCallBaseUrl by appPreferencesStore
.getCustomElementCallBaseUrlFlow() .getCustomElementCallBaseUrlFlow()
.collectAsState(initial = null) .collectAsState(initial = null)
val hideImagesAndVideos by appPreferencesStore
.doesHideImagesAndVideosFlow()
.collectAsState(initial = false)
val tracingLogLevelFlow = remember { val tracingLogLevelFlow = remember {
appPreferencesStore.getTracingLogLevelFlow().map { AsyncData.Success(it.toLogLevelItem()) } appPreferencesStore.getTracingLogLevelFlow().map { AsyncData.Success(it.toLogLevelItem()) }
@ -126,9 +123,6 @@ class DeveloperSettingsPresenter @Inject constructor(
appPreferencesStore.setCustomElementCallBaseUrl(urlToSave) appPreferencesStore.setCustomElementCallBaseUrl(urlToSave)
} }
DeveloperSettingsEvents.ClearCache -> coroutineScope.clearCache(clearCacheAction) DeveloperSettingsEvents.ClearCache -> coroutineScope.clearCache(clearCacheAction)
is DeveloperSettingsEvents.SetHideImagesAndVideos -> coroutineScope.launch {
appPreferencesStore.setHideImagesAndVideos(event.value)
}
is DeveloperSettingsEvents.SetTracingLogLevel -> coroutineScope.launch { is DeveloperSettingsEvents.SetTracingLogLevel -> coroutineScope.launch {
appPreferencesStore.setTracingLogLevel(event.logLevel.toLogLevel()) appPreferencesStore.setTracingLogLevel(event.logLevel.toLogLevel())
} }
@ -153,7 +147,6 @@ class DeveloperSettingsPresenter @Inject constructor(
baseUrl = customElementCallBaseUrl, baseUrl = customElementCallBaseUrl,
validator = ::customElementCallUrlValidator, validator = ::customElementCallUrlValidator,
), ),
hideImagesAndVideos = hideImagesAndVideos,
tracingLogLevel = tracingLogLevel, tracingLogLevel = tracingLogLevel,
tracingLogPacks = tracingLogPacks, tracingLogPacks = tracingLogPacks,
eventSink = ::handleEvents eventSink = ::handleEvents

View file

@ -21,7 +21,6 @@ data class DeveloperSettingsState(
val rageshakeState: RageshakePreferencesState, val rageshakeState: RageshakePreferencesState,
val clearCacheAction: AsyncAction<Unit>, val clearCacheAction: AsyncAction<Unit>,
val customElementCallBaseUrlState: CustomElementCallBaseUrlState, val customElementCallBaseUrlState: CustomElementCallBaseUrlState,
val hideImagesAndVideos: Boolean,
val tracingLogLevel: AsyncData<LogLevelItem>, val tracingLogLevel: AsyncData<LogLevelItem>,
val tracingLogPacks: ImmutableList<TraceLogPack>, val tracingLogPacks: ImmutableList<TraceLogPack>,
val eventSink: (DeveloperSettingsEvents) -> Unit val eventSink: (DeveloperSettingsEvents) -> Unit

View file

@ -34,7 +34,6 @@ open class DeveloperSettingsStateProvider : PreviewParameterProvider<DeveloperSe
fun aDeveloperSettingsState( fun aDeveloperSettingsState(
clearCacheAction: AsyncAction<Unit> = AsyncAction.Uninitialized, clearCacheAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
customElementCallBaseUrlState: CustomElementCallBaseUrlState = aCustomElementCallBaseUrlState(), customElementCallBaseUrlState: CustomElementCallBaseUrlState = aCustomElementCallBaseUrlState(),
hideImagesAndVideos: Boolean = false,
traceLogPacks: List<TraceLogPack> = emptyList(), traceLogPacks: List<TraceLogPack> = emptyList(),
eventSink: (DeveloperSettingsEvents) -> Unit = {}, eventSink: (DeveloperSettingsEvents) -> Unit = {},
) = DeveloperSettingsState( ) = DeveloperSettingsState(
@ -43,7 +42,6 @@ fun aDeveloperSettingsState(
cacheSize = AsyncData.Success("1.2 MB"), cacheSize = AsyncData.Success("1.2 MB"),
clearCacheAction = clearCacheAction, clearCacheAction = clearCacheAction,
customElementCallBaseUrlState = customElementCallBaseUrlState, customElementCallBaseUrlState = customElementCallBaseUrlState,
hideImagesAndVideos = hideImagesAndVideos,
tracingLogLevel = AsyncData.Success(LogLevelItem.INFO), tracingLogLevel = AsyncData.Success(LogLevelItem.INFO),
tracingLogPacks = traceLogPacks.toPersistentList(), tracingLogPacks = traceLogPacks.toPersistentList(),
eventSink = eventSink, eventSink = eventSink,

View file

@ -51,7 +51,6 @@ fun DeveloperSettingsView(
title = stringResource(id = CommonStrings.common_developer_options) title = stringResource(id = CommonStrings.common_developer_options)
) { ) {
// Note: this is OK to hardcode strings in this debug screen. // Note: this is OK to hardcode strings in this debug screen.
SettingsCategory(state)
PreferenceCategory( PreferenceCategory(
title = "Feature flags", title = "Feature flags",
showTopDivider = true, showTopDivider = true,
@ -134,22 +133,6 @@ fun DeveloperSettingsView(
} }
} }
@Composable
private fun SettingsCategory(
state: DeveloperSettingsState,
) {
PreferenceCategory(title = "Preferences", showTopDivider = false) {
PreferenceSwitch(
title = "Hide image & video previews",
subtitle = "When toggled image & video will not render in the timeline by default.",
isChecked = state.hideImagesAndVideos,
onCheckedChange = {
state.eventSink(DeveloperSettingsEvents.SetHideImagesAndVideos(it))
}
)
}
}
@Composable @Composable
private fun ElementCallCategory( private fun ElementCallCategory(
state: DeveloperSettingsState, state: DeveloperSettingsState,

View file

@ -19,6 +19,11 @@
<string name="screen_advanced_settings_send_read_receipts_description">"If turned off, your read receipts won\'t be sent to anyone. You will still receive read receipts from other users."</string> <string name="screen_advanced_settings_send_read_receipts_description">"If turned off, your read receipts won\'t be sent to anyone. You will still receive read receipts from other users."</string>
<string name="screen_advanced_settings_share_presence">"Share presence"</string> <string name="screen_advanced_settings_share_presence">"Share presence"</string>
<string name="screen_advanced_settings_share_presence_description">"If turned off, you wont be able to send or receive read receipts or typing notifications."</string> <string name="screen_advanced_settings_share_presence_description">"If turned off, you wont 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_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_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> <string name="screen_blocked_users_empty">"You have no blocked users"</string>
<string name="screen_blocked_users_unblock_alert_action">"Unblock"</string> <string name="screen_blocked_users_unblock_alert_action">"Unblock"</string>

View file

@ -147,28 +147,6 @@ class DeveloperSettingsPresenterTest {
} }
} }
@Test
fun `present - toggling hide image and video`() = runTest {
val preferences = InMemoryAppPreferencesStore()
val presenter = createDeveloperSettingsPresenter(preferencesStore = preferences)
presenter.test {
skipItems(2)
awaitItem().also { state ->
assertThat(state.hideImagesAndVideos).isFalse()
state.eventSink(DeveloperSettingsEvents.SetHideImagesAndVideos(true))
}
awaitItem().also { state ->
assertThat(state.hideImagesAndVideos).isTrue()
assertThat(preferences.doesHideImagesAndVideosFlow().first()).isTrue()
state.eventSink(DeveloperSettingsEvents.SetHideImagesAndVideos(false))
}
awaitItem().also { state ->
assertThat(state.hideImagesAndVideos).isFalse()
assertThat(preferences.doesHideImagesAndVideosFlow().first()).isFalse()
}
}
}
@Test @Test
fun `present - changing tracing log level`() = runTest { fun `present - changing tracing log level`() = runTest {
val preferences = InMemoryAppPreferencesStore() val preferences = InMemoryAppPreferencesStore()

View file

@ -109,18 +109,6 @@ class DeveloperSettingsViewTest {
rule.onNodeWithText("Clear cache").performClick() rule.onNodeWithText("Clear cache").performClick()
eventsRecorder.assertSingle(DeveloperSettingsEvents.ClearCache) eventsRecorder.assertSingle(DeveloperSettingsEvents.ClearCache)
} }
@Test
fun `clicking on the hide images and videos switch emits the expected event`() {
val eventsRecorder = EventsRecorder<DeveloperSettingsEvents>()
rule.setDeveloperSettingsView(
state = aDeveloperSettingsState(
eventSink = eventsRecorder
),
)
rule.onNodeWithText("Hide image & video previews").performClick()
eventsRecorder.assertSingle(DeveloperSettingsEvents.SetHideImagesAndVideos(true))
}
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setDeveloperSettingsView( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setDeveloperSettingsView(

View file

@ -0,0 +1,20 @@
/*
* Copyright 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.libraries.matrix.api.media
/**
* Represents the values for media preview settings.
* - [On] means that media preview are enabled
* - [Off] means that media preview are disabled
* - [Private] means that media preview are enabled only for private chats.
*/
enum class MediaPreviewValue {
On,
Off,
Private
}

View file

@ -7,6 +7,7 @@
package io.element.android.libraries.preferences.api.store package io.element.android.libraries.preferences.api.store
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.matrix.api.tracing.LogLevel import io.element.android.libraries.matrix.api.tracing.LogLevel
import io.element.android.libraries.matrix.api.tracing.TraceLogPack import io.element.android.libraries.matrix.api.tracing.TraceLogPack
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -21,8 +22,11 @@ interface AppPreferencesStore {
suspend fun setTheme(theme: String) suspend fun setTheme(theme: String)
fun getThemeFlow(): Flow<String?> fun getThemeFlow(): Flow<String?>
suspend fun setHideImagesAndVideos(value: Boolean) suspend fun setHideInviteAvatars(value: Boolean)
fun doesHideImagesAndVideosFlow(): Flow<Boolean> fun getHideInviteAvatarsFlow(): Flow<Boolean>
suspend fun setTimelineMediaPreviewValue(value: MediaPreviewValue)
fun getTimelineMediaPreviewValueFlow(): Flow<MediaPreviewValue>
suspend fun setTracingLogLevel(logLevel: LogLevel) suspend fun setTracingLogLevel(logLevel: LogLevel)
fun getTracingLogLevelFlow(): Flow<LogLevel> fun getTracingLogLevelFlow(): Flow<LogLevel>

View file

@ -19,6 +19,7 @@ import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.matrix.api.tracing.LogLevel import io.element.android.libraries.matrix.api.tracing.LogLevel
import io.element.android.libraries.matrix.api.tracing.TraceLogPack import io.element.android.libraries.matrix.api.tracing.TraceLogPack
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
@ -31,7 +32,8 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(na
private val developerModeKey = booleanPreferencesKey("developerMode") private val developerModeKey = booleanPreferencesKey("developerMode")
private val customElementCallBaseUrlKey = stringPreferencesKey("elementCallBaseUrl") private val customElementCallBaseUrlKey = stringPreferencesKey("elementCallBaseUrl")
private val themeKey = stringPreferencesKey("theme") private val themeKey = stringPreferencesKey("theme")
private val hideImagesAndVideosKey = booleanPreferencesKey("hideImagesAndVideos") private val hideInviteAvatarsKey = booleanPreferencesKey("hideInviteAvatars")
private val timelineMediaPreviewValueKey = stringPreferencesKey("timelineMediaPreviewValue")
private val logLevelKey = stringPreferencesKey("logLevel") private val logLevelKey = stringPreferencesKey("logLevel")
private val traceLogPacksKey = stringPreferencesKey("traceLogPacks") private val traceLogPacksKey = stringPreferencesKey("traceLogPacks")
@ -83,15 +85,27 @@ class DefaultAppPreferencesStore @Inject constructor(
} }
} }
override suspend fun setHideImagesAndVideos(value: Boolean) { override suspend fun setHideInviteAvatars(value: Boolean) {
store.edit { prefs -> store.edit { prefs ->
prefs[hideImagesAndVideosKey] = value prefs[hideInviteAvatarsKey] = value
} }
} }
override fun doesHideImagesAndVideosFlow(): Flow<Boolean> { override fun getHideInviteAvatarsFlow(): Flow<Boolean> {
return store.data.map { prefs -> return store.data.map { prefs ->
prefs[hideImagesAndVideosKey] ?: false prefs[hideInviteAvatarsKey] == true
}
}
override suspend fun setTimelineMediaPreviewValue(value: MediaPreviewValue) {
store.edit { prefs ->
prefs[timelineMediaPreviewValueKey] = value.name
}
}
override fun getTimelineMediaPreviewValueFlow(): Flow<MediaPreviewValue> {
return store.data.map { prefs ->
prefs[timelineMediaPreviewValueKey]?.let { MediaPreviewValue.valueOf(it) } ?: MediaPreviewValue.On
} }
} }

View file

@ -7,6 +7,7 @@
package io.element.android.libraries.preferences.test package io.element.android.libraries.preferences.test
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.matrix.api.tracing.LogLevel import io.element.android.libraries.matrix.api.tracing.LogLevel
import io.element.android.libraries.matrix.api.tracing.TraceLogPack import io.element.android.libraries.matrix.api.tracing.TraceLogPack
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
@ -15,18 +16,20 @@ import kotlinx.coroutines.flow.MutableStateFlow
class InMemoryAppPreferencesStore( class InMemoryAppPreferencesStore(
isDeveloperModeEnabled: Boolean = false, isDeveloperModeEnabled: Boolean = false,
hideImagesAndVideos: Boolean = false,
customElementCallBaseUrl: String? = null, customElementCallBaseUrl: String? = null,
hideInviteAvatars: Boolean = false,
timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On,
theme: String? = null, theme: String? = null,
logLevel: LogLevel = LogLevel.INFO, logLevel: LogLevel = LogLevel.INFO,
traceLockPacks: Set<TraceLogPack> = emptySet(), traceLockPacks: Set<TraceLogPack> = emptySet(),
) : AppPreferencesStore { ) : AppPreferencesStore {
private val isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled) private val isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled)
private val hideImagesAndVideos = MutableStateFlow(hideImagesAndVideos)
private val customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl) private val customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl)
private val theme = MutableStateFlow(theme) private val theme = MutableStateFlow(theme)
private val logLevel = MutableStateFlow(logLevel) private val logLevel = MutableStateFlow(logLevel)
private val tracingLogPacks = MutableStateFlow(traceLockPacks) private val tracingLogPacks = MutableStateFlow(traceLockPacks)
private val hideInviteAvatars = MutableStateFlow(hideInviteAvatars)
private val timelineMediaPreviewValue = MutableStateFlow(timelineMediaPreviewValue)
override suspend fun setDeveloperModeEnabled(enabled: Boolean) { override suspend fun setDeveloperModeEnabled(enabled: Boolean) {
isDeveloperModeEnabled.value = enabled isDeveloperModeEnabled.value = enabled
@ -52,12 +55,20 @@ class InMemoryAppPreferencesStore(
return theme return theme
} }
override suspend fun setHideImagesAndVideos(value: Boolean) { override suspend fun setHideInviteAvatars(value: Boolean) {
hideImagesAndVideos.value = value hideInviteAvatars.value = value
} }
override fun doesHideImagesAndVideosFlow(): Flow<Boolean> { override fun getHideInviteAvatarsFlow(): Flow<Boolean> {
return hideImagesAndVideos return hideInviteAvatars
}
override suspend fun setTimelineMediaPreviewValue(value: MediaPreviewValue) {
timelineMediaPreviewValue.value = value
}
override fun getTimelineMediaPreviewValueFlow(): Flow<MediaPreviewValue> {
return timelineMediaPreviewValue
} }
override suspend fun setTracingLogLevel(logLevel: LogLevel) { override suspend fun setTracingLogLevel(logLevel: LogLevel) {

View file

@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.matrix.api.notification.NotificationContent import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.notification.NotificationData import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.permalink.PermalinkParser
@ -292,7 +293,7 @@ class DefaultNotifiableEventResolver @Inject constructor(
} }
private suspend fun NotificationContent.MessageLike.RoomMessage.fetchImageIfPresent(client: MatrixClient): Uri? { private suspend fun NotificationContent.MessageLike.RoomMessage.fetchImageIfPresent(client: MatrixClient): Uri? {
if (appPreferencesStore.doesHideImagesAndVideosFlow().first()) { if (appPreferencesStore.getTimelineMediaPreviewValueFlow().first() != MediaPreviewValue.On) {
return null return null
} }
val fileResult = when (val messageType = messageType) { val fileResult = when (val messageType = messageType) {
@ -319,7 +320,7 @@ class DefaultNotifiableEventResolver @Inject constructor(
} }
private suspend fun NotificationContent.MessageLike.RoomMessage.getImageMimetype(): String? { private suspend fun NotificationContent.MessageLike.RoomMessage.getImageMimetype(): String? {
if (appPreferencesStore.doesHideImagesAndVideosFlow().first()) { if (appPreferencesStore.getTimelineMediaPreviewValueFlow().first() != MediaPreviewValue.On) {
return null return null
} }
return when (val messageType = messageType) { return when (val messageType = messageType) {