Merge pull request #4574 from element-hq/feature/fga/advanced_settings_moderation_and_safety
change (preferences) : new moderation and safety settings
This commit is contained in:
commit
ce0bac55c5
67 changed files with 382 additions and 147 deletions
|
|
@ -35,6 +35,7 @@ dependencies {
|
||||||
implementation(projects.features.invite.api)
|
implementation(projects.features.invite.api)
|
||||||
implementation(projects.features.roomdirectory.api)
|
implementation(projects.features.roomdirectory.api)
|
||||||
implementation(projects.services.analytics.api)
|
implementation(projects.services.analytics.api)
|
||||||
|
implementation(projects.libraries.preferences.api)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
testImplementation(libs.test.junit)
|
||||||
testImplementation(libs.coroutines.test)
|
testImplementation(libs.coroutines.test)
|
||||||
|
|
@ -46,5 +47,6 @@ dependencies {
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.tests.testutils)
|
testImplementation(projects.tests.testutils)
|
||||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||||
|
testImplementation(projects.libraries.preferences.test)
|
||||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ import io.element.android.libraries.matrix.api.room.join.JoinRoom
|
||||||
import io.element.android.libraries.matrix.api.room.join.JoinRule
|
import io.element.android.libraries.matrix.api.room.join.JoinRule
|
||||||
import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo
|
import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo
|
||||||
import io.element.android.libraries.matrix.ui.model.toInviteSender
|
import io.element.android.libraries.matrix.ui.model.toInviteSender
|
||||||
|
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.Optional
|
import java.util.Optional
|
||||||
|
|
@ -69,6 +70,7 @@ class JoinRoomPresenter @AssistedInject constructor(
|
||||||
private val forgetRoom: ForgetRoom,
|
private val forgetRoom: ForgetRoom,
|
||||||
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
||||||
private val buildMeta: BuildMeta,
|
private val buildMeta: BuildMeta,
|
||||||
|
private val appPreferencesStore: AppPreferencesStore,
|
||||||
private val seenInvitesStore: SeenInvitesStore,
|
private val seenInvitesStore: SeenInvitesStore,
|
||||||
) : Presenter<JoinRoomState> {
|
) : Presenter<JoinRoomState> {
|
||||||
interface Factory {
|
interface Factory {
|
||||||
|
|
@ -94,6 +96,9 @@ class JoinRoomPresenter @AssistedInject constructor(
|
||||||
val forgetRoomAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
val forgetRoomAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||||
var knockMessage by rememberSaveable { mutableStateOf("") }
|
var knockMessage by rememberSaveable { mutableStateOf("") }
|
||||||
var isDismissingContent by remember { mutableStateOf(false) }
|
var isDismissingContent by remember { mutableStateOf(false) }
|
||||||
|
val hideInviteAvatars by remember {
|
||||||
|
appPreferencesStore.getHideInviteAvatarsFlow()
|
||||||
|
}.collectAsState(initial = false)
|
||||||
val contentState by produceState<ContentState>(
|
val contentState by produceState<ContentState>(
|
||||||
initialValue = ContentState.Loading,
|
initialValue = ContentState.Loading,
|
||||||
key1 = roomInfo,
|
key1 = roomInfo,
|
||||||
|
|
@ -202,6 +207,7 @@ class JoinRoomPresenter @AssistedInject constructor(
|
||||||
cancelKnockAction = cancelKnockAction.value,
|
cancelKnockAction = cancelKnockAction.value,
|
||||||
applicationName = buildMeta.applicationName,
|
applicationName = buildMeta.applicationName,
|
||||||
knockMessage = knockMessage,
|
knockMessage = knockMessage,
|
||||||
|
hideInviteAvatars = hideInviteAvatars,
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ data class JoinRoomState(
|
||||||
val cancelKnockAction: AsyncAction<Unit>,
|
val cancelKnockAction: AsyncAction<Unit>,
|
||||||
private val applicationName: String,
|
private val applicationName: String,
|
||||||
val knockMessage: String,
|
val knockMessage: String,
|
||||||
|
val hideInviteAvatars: Boolean,
|
||||||
val eventSink: (JoinRoomEvents) -> Unit
|
val eventSink: (JoinRoomEvents) -> Unit
|
||||||
) {
|
) {
|
||||||
val isJoinActionUnauthorized = joinAction is AsyncAction.Failure && joinAction.error is JoinRoomFailures.UnauthorizedJoin
|
val isJoinActionUnauthorized = joinAction is AsyncAction.Failure && joinAction.error is JoinRoomFailures.UnauthorizedJoin
|
||||||
|
|
@ -57,6 +58,8 @@ data class JoinRoomState(
|
||||||
}
|
}
|
||||||
else -> JoinAuthorisationStatus.None
|
else -> JoinAuthorisationStatus.None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val hideAvatarsImages = hideInviteAvatars && joinAuthorisationStatus is JoinAuthorisationStatus.IsInvited
|
||||||
}
|
}
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,7 @@ fun aJoinRoomState(
|
||||||
forgetAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
forgetAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||||
cancelKnockAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
cancelKnockAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||||
knockMessage: String = "",
|
knockMessage: String = "",
|
||||||
|
hideInviteAvatars: Boolean = false,
|
||||||
eventSink: (JoinRoomEvents) -> Unit = {}
|
eventSink: (JoinRoomEvents) -> Unit = {}
|
||||||
) = JoinRoomState(
|
) = JoinRoomState(
|
||||||
roomIdOrAlias = roomIdOrAlias,
|
roomIdOrAlias = roomIdOrAlias,
|
||||||
|
|
@ -182,6 +183,7 @@ fun aJoinRoomState(
|
||||||
forgetAction = forgetAction,
|
forgetAction = forgetAction,
|
||||||
applicationName = "AppName",
|
applicationName = "AppName",
|
||||||
knockMessage = knockMessage,
|
knockMessage = knockMessage,
|
||||||
|
hideInviteAvatars = hideInviteAvatars,
|
||||||
eventSink = eventSink
|
eventSink = eventSink
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,7 @@ fun JoinRoomView(
|
||||||
roomIdOrAlias = state.roomIdOrAlias,
|
roomIdOrAlias = state.roomIdOrAlias,
|
||||||
contentState = state.contentState,
|
contentState = state.contentState,
|
||||||
knockMessage = state.knockMessage,
|
knockMessage = state.knockMessage,
|
||||||
|
hideAvatarsImages = state.hideAvatarsImages,
|
||||||
onKnockMessageUpdate = { state.eventSink(JoinRoomEvents.UpdateKnockMessage(it)) },
|
onKnockMessageUpdate = { state.eventSink(JoinRoomEvents.UpdateKnockMessage(it)) },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -371,6 +372,7 @@ private fun JoinRoomContent(
|
||||||
roomIdOrAlias: RoomIdOrAlias,
|
roomIdOrAlias: RoomIdOrAlias,
|
||||||
contentState: ContentState,
|
contentState: ContentState,
|
||||||
knockMessage: String,
|
knockMessage: String,
|
||||||
|
hideAvatarsImages: Boolean,
|
||||||
onKnockMessageUpdate: (String) -> Unit,
|
onKnockMessageUpdate: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -385,13 +387,14 @@ private fun JoinRoomContent(
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
val inviteSender = (contentState.joinAuthorisationStatus as? JoinAuthorisationStatus.IsInvited)?.inviteSender
|
val inviteSender = (contentState.joinAuthorisationStatus as? JoinAuthorisationStatus.IsInvited)?.inviteSender
|
||||||
if (inviteSender != null) {
|
if (inviteSender != null) {
|
||||||
InviteSenderView(inviteSender = inviteSender)
|
InviteSenderView(inviteSender = inviteSender, hideAvatarImage = hideAvatarsImages)
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
}
|
}
|
||||||
DefaultLoadedContent(
|
DefaultLoadedContent(
|
||||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||||
contentState = contentState,
|
contentState = contentState,
|
||||||
knockMessage = knockMessage,
|
knockMessage = knockMessage,
|
||||||
|
hideAvatarImage = hideAvatarsImages,
|
||||||
onKnockMessageUpdate = onKnockMessageUpdate
|
onKnockMessageUpdate = onKnockMessageUpdate
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -474,13 +477,14 @@ private fun IsKnockedLoadedContent(modifier: Modifier = Modifier) {
|
||||||
private fun DefaultLoadedContent(
|
private fun DefaultLoadedContent(
|
||||||
contentState: ContentState.Loaded,
|
contentState: ContentState.Loaded,
|
||||||
knockMessage: String,
|
knockMessage: String,
|
||||||
|
hideAvatarImage: Boolean,
|
||||||
onKnockMessageUpdate: (String) -> Unit,
|
onKnockMessageUpdate: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
RoomPreviewOrganism(
|
RoomPreviewOrganism(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
avatar = {
|
avatar = {
|
||||||
Avatar(contentState.avatarData(AvatarSize.RoomHeader))
|
Avatar(contentState.avatarData(AvatarSize.RoomHeader), hideImage = hideAvatarImage)
|
||||||
},
|
},
|
||||||
title = {
|
title = {
|
||||||
if (contentState.name != null) {
|
if (contentState.name != null) {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||||
import io.element.android.libraries.matrix.api.room.join.JoinRoom
|
import io.element.android.libraries.matrix.api.room.join.JoinRoom
|
||||||
|
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||||
import java.util.Optional
|
import java.util.Optional
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
|
|
@ -36,6 +37,7 @@ object JoinRoomModule {
|
||||||
forgetRoom: ForgetRoom,
|
forgetRoom: ForgetRoom,
|
||||||
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
||||||
buildMeta: BuildMeta,
|
buildMeta: BuildMeta,
|
||||||
|
appPreferencesStore: AppPreferencesStore,
|
||||||
seenInvitesStore: SeenInvitesStore,
|
seenInvitesStore: SeenInvitesStore,
|
||||||
): JoinRoomPresenter.Factory {
|
): JoinRoomPresenter.Factory {
|
||||||
return object : JoinRoomPresenter.Factory {
|
return object : JoinRoomPresenter.Factory {
|
||||||
|
|
@ -59,6 +61,7 @@ object JoinRoomModule {
|
||||||
cancelKnockRoom = cancelKnockRoom,
|
cancelKnockRoom = cancelKnockRoom,
|
||||||
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
|
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
|
||||||
buildMeta = buildMeta,
|
buildMeta = buildMeta,
|
||||||
|
appPreferencesStore = appPreferencesStore,
|
||||||
seenInvitesStore = seenInvitesStore,
|
seenInvitesStore = seenInvitesStore,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,8 @@ import io.element.android.libraries.matrix.test.room.aRoomPreviewInfo
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomSummary
|
import io.element.android.libraries.matrix.test.room.aRoomSummary
|
||||||
import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom
|
import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom
|
||||||
import io.element.android.libraries.matrix.ui.model.toInviteSender
|
import io.element.android.libraries.matrix.ui.model.toInviteSender
|
||||||
|
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||||
|
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import io.element.android.tests.testutils.lambda.any
|
import io.element.android.tests.testutils.lambda.any
|
||||||
import io.element.android.tests.testutils.lambda.assert
|
import io.element.android.tests.testutils.lambda.assert
|
||||||
|
|
@ -768,6 +770,7 @@ class JoinRoomPresenterTest {
|
||||||
forgetRoom: ForgetRoom = FakeForgetRoom(),
|
forgetRoom: ForgetRoom = FakeForgetRoom(),
|
||||||
buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"),
|
buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"),
|
||||||
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
|
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
|
||||||
|
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
|
||||||
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
|
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
|
||||||
): JoinRoomPresenter {
|
): JoinRoomPresenter {
|
||||||
return JoinRoomPresenter(
|
return JoinRoomPresenter(
|
||||||
|
|
@ -783,6 +786,7 @@ class JoinRoomPresenterTest {
|
||||||
forgetRoom = forgetRoom,
|
forgetRoom = forgetRoom,
|
||||||
buildMeta = buildMeta,
|
buildMeta = buildMeta,
|
||||||
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
|
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
|
||||||
|
appPreferencesStore = appPreferencesStore,
|
||||||
seenInvitesStore = seenInvitesStore,
|
seenInvitesStore = seenInvitesStore,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,25 +16,32 @@ 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.matrix.api.media.isPreviewEnabled
|
||||||
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
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
|
||||||
|
|
||||||
class TimelineProtectionPresenter @Inject constructor(
|
class TimelineProtectionPresenter @Inject constructor(
|
||||||
private val appPreferencesStore: AppPreferencesStore,
|
private val appPreferencesStore: AppPreferencesStore,
|
||||||
|
private val room: MatrixRoom,
|
||||||
) : Presenter<TimelineProtectionState> {
|
) : Presenter<TimelineProtectionState> {
|
||||||
|
private val allowedEvents = mutableStateOf<Set<EventId>>(setOf())
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): TimelineProtectionState {
|
override fun present(): TimelineProtectionState {
|
||||||
val hideMediaContent by remember {
|
val mediaPreviewValue = remember {
|
||||||
appPreferencesStore.doesHideImagesAndVideosFlow()
|
appPreferencesStore.getTimelineMediaPreviewValueFlow()
|
||||||
}.collectAsState(initial = false)
|
}.collectAsState(initial = MediaPreviewValue.On)
|
||||||
var allowedEvents by remember { mutableStateOf<Set<EventId>>(setOf()) }
|
val roomInfo = room.roomInfoFlow.collectAsState()
|
||||||
val protectionState by remember(hideMediaContent) {
|
val protectionState by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
if (hideMediaContent) {
|
val isPreviewEnabled = mediaPreviewValue.value.isPreviewEnabled(roomInfo.value.joinRule)
|
||||||
ProtectionState.RenderOnly(eventIds = allowedEvents.toImmutableSet())
|
if (isPreviewEnabled) {
|
||||||
} else {
|
|
||||||
ProtectionState.RenderAll
|
ProtectionState.RenderAll
|
||||||
|
} else {
|
||||||
|
ProtectionState.RenderOnly(eventIds = allowedEvents.value.toImmutableSet())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -42,7 +49,7 @@ class TimelineProtectionPresenter @Inject constructor(
|
||||||
fun handleEvent(event: TimelineProtectionEvent) {
|
fun handleEvent(event: TimelineProtectionEvent) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is TimelineProtectionEvent.ShowContent -> {
|
is TimelineProtectionEvent.ShowContent -> {
|
||||||
allowedEvents = allowedEvents + setOfNotNull(event.eventId)
|
allowedEvents.value = allowedEvents.value + setOfNotNull(event.eventId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,12 @@
|
||||||
package io.element.android.features.messages.impl.timeline.protection
|
package io.element.android.features.messages.impl.timeline.protection
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
||||||
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
|
import io.element.android.libraries.matrix.api.room.join.JoinRule
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
|
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||||
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
|
|
@ -32,8 +37,8 @@ class TimelineProtectionPresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - protected`() = runTest {
|
fun `present - media preview value off`() = runTest {
|
||||||
val appPreferencesStore = InMemoryAppPreferencesStore(hideImagesAndVideos = true)
|
val appPreferencesStore = InMemoryAppPreferencesStore(timelineMediaPreviewValue = MediaPreviewValue.Off)
|
||||||
val presenter = createPresenter(appPreferencesStore)
|
val presenter = createPresenter(appPreferencesStore)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
|
|
@ -47,9 +52,42 @@ class TimelineProtectionPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - media preview value private in public room`() = runTest {
|
||||||
|
val appPreferencesStore = InMemoryAppPreferencesStore(timelineMediaPreviewValue = MediaPreviewValue.Private)
|
||||||
|
val room = FakeMatrixRoom(initialRoomInfo = aRoomInfo(joinRule = JoinRule.Public))
|
||||||
|
val presenter = createPresenter(appPreferencesStore, room)
|
||||||
|
presenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf()))
|
||||||
|
// ShowContent with null should have no effect.
|
||||||
|
initialState.eventSink(TimelineProtectionEvent.ShowContent(eventId = null))
|
||||||
|
initialState.eventSink(TimelineProtectionEvent.ShowContent(eventId = AN_EVENT_ID))
|
||||||
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf(AN_EVENT_ID)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - media preview value private in non public room`() = runTest {
|
||||||
|
val appPreferencesStore = InMemoryAppPreferencesStore(timelineMediaPreviewValue = MediaPreviewValue.Private)
|
||||||
|
val room = FakeMatrixRoom(initialRoomInfo = aRoomInfo(joinRule = JoinRule.Invite))
|
||||||
|
val presenter = createPresenter(appPreferencesStore, room)
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderAll)
|
||||||
|
// ShowContent with null should have no effect.
|
||||||
|
initialState.eventSink(TimelineProtectionEvent.ShowContent(eventId = null))
|
||||||
|
initialState.eventSink(TimelineProtectionEvent.ShowContent(eventId = AN_EVENT_ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun createPresenter(
|
private fun createPresenter(
|
||||||
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
|
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
|
||||||
|
room: MatrixRoom = FakeMatrixRoom(),
|
||||||
) = TimelineProtectionPresenter(
|
) = TimelineProtectionPresenter(
|
||||||
appPreferencesStore = appPreferencesStore,
|
appPreferencesStore = appPreferencesStore,
|
||||||
|
room = room,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -43,6 +44,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 {
|
||||||
|
|
@ -60,6 +69,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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,6 +84,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) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,22 @@ import im.vector.app.features.analytics.plan.Interaction
|
||||||
import io.element.android.compound.theme.Theme
|
import io.element.android.compound.theme.Theme
|
||||||
import io.element.android.compound.theme.themes
|
import io.element.android.compound.theme.themes
|
||||||
import io.element.android.features.preferences.impl.R
|
import io.element.android.features.preferences.impl.R
|
||||||
|
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
|
||||||
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.PreferencePage
|
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
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.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 {
|
||||||
|
|
@ -134,9 +194,21 @@ private fun Theme.toHumanReadable(): String {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewWithLargeHeight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun AdvancedSettingsViewPreview(@PreviewParameter(AdvancedSettingsStateProvider::class) state: AdvancedSettingsState) =
|
internal fun AdvancedSettingsViewLightPreview(@PreviewParameter(AdvancedSettingsStateProvider::class) state: AdvancedSettingsState) =
|
||||||
ElementPreview {
|
ElementPreviewLight { ContentToPreview(state) }
|
||||||
AdvancedSettingsView(state = state, onBackClick = { })
|
|
||||||
}
|
@PreviewWithLargeHeight
|
||||||
|
@Composable
|
||||||
|
internal fun AdvancedSettingsViewDarkPreview(@PreviewParameter(AdvancedSettingsStateProvider::class) state: AdvancedSettingsState) =
|
||||||
|
ElementPreviewDark { ContentToPreview(state) }
|
||||||
|
|
||||||
|
@ExcludeFromCoverage
|
||||||
|
@Composable
|
||||||
|
private fun ContentToPreview(state: AdvancedSettingsState) {
|
||||||
|
AdvancedSettingsView(
|
||||||
|
state = state,
|
||||||
|
onBackClick = { }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -75,10 +75,6 @@ class DeveloperSettingsPresenter @Inject constructor(
|
||||||
appPreferencesStore
|
appPreferencesStore
|
||||||
.getCustomElementCallBaseUrlFlow()
|
.getCustomElementCallBaseUrlFlow()
|
||||||
}.collectAsState(initial = null)
|
}.collectAsState(initial = null)
|
||||||
val hideImagesAndVideos by remember {
|
|
||||||
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()) }
|
||||||
|
|
@ -128,9 +124,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())
|
||||||
}
|
}
|
||||||
|
|
@ -155,7 +148,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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 won’t be able to send or receive read receipts or typing notifications."</string>
|
<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_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>
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@ class DeveloperSettingsPresenterTest {
|
||||||
assertThat(state.cacheSize).isEqualTo(AsyncData.Uninitialized)
|
assertThat(state.cacheSize).isEqualTo(AsyncData.Uninitialized)
|
||||||
assertThat(state.customElementCallBaseUrlState).isNotNull()
|
assertThat(state.customElementCallBaseUrlState).isNotNull()
|
||||||
assertThat(state.customElementCallBaseUrlState.baseUrl).isNull()
|
assertThat(state.customElementCallBaseUrlState.baseUrl).isNull()
|
||||||
assertThat(state.hideImagesAndVideos).isFalse()
|
|
||||||
assertThat(state.rageshakeState.isEnabled).isFalse()
|
assertThat(state.rageshakeState.isEnabled).isFalse()
|
||||||
assertThat(state.rageshakeState.isSupported).isTrue()
|
assertThat(state.rageshakeState.isSupported).isTrue()
|
||||||
assertThat(state.rageshakeState.sensitivity).isEqualTo(0.3f)
|
assertThat(state.rageshakeState.sensitivity).isEqualTo(0.3f)
|
||||||
|
|
@ -147,28 +146,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()
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,9 @@ class RoomListPresenter @Inject constructor(
|
||||||
|
|
||||||
// Avatar indicator
|
// Avatar indicator
|
||||||
val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator()
|
val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator()
|
||||||
|
val hideInvitesAvatar by remember {
|
||||||
|
appPreferencesStore.getHideInviteAvatarsFlow()
|
||||||
|
}.collectAsState(initial = false)
|
||||||
|
|
||||||
val contextMenu = remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) }
|
val contextMenu = remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) }
|
||||||
|
|
||||||
|
|
@ -173,6 +176,7 @@ class RoomListPresenter @Inject constructor(
|
||||||
contentState = contentState,
|
contentState = contentState,
|
||||||
acceptDeclineInviteState = acceptDeclineInviteState,
|
acceptDeclineInviteState = acceptDeclineInviteState,
|
||||||
directLogoutState = directLogoutState,
|
directLogoutState = directLogoutState,
|
||||||
|
hideInvitesAvatars = hideInvitesAvatar,
|
||||||
eventSink = ::handleEvents,
|
eventSink = ::handleEvents,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ data class RoomListState(
|
||||||
val contentState: RoomListContentState,
|
val contentState: RoomListContentState,
|
||||||
val acceptDeclineInviteState: AcceptDeclineInviteState,
|
val acceptDeclineInviteState: AcceptDeclineInviteState,
|
||||||
val directLogoutState: DirectLogoutState,
|
val directLogoutState: DirectLogoutState,
|
||||||
|
val hideInvitesAvatars: Boolean,
|
||||||
val eventSink: (RoomListEvents) -> Unit,
|
val eventSink: (RoomListEvents) -> Unit,
|
||||||
) {
|
) {
|
||||||
val displayFilters = contentState is RoomListContentState.Rooms
|
val displayFilters = contentState is RoomListContentState.Rooms
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ internal fun aRoomListState(
|
||||||
contentState: RoomListContentState = aRoomsContentState(),
|
contentState: RoomListContentState = aRoomsContentState(),
|
||||||
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
|
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
|
||||||
directLogoutState: DirectLogoutState = aDirectLogoutState(),
|
directLogoutState: DirectLogoutState = aDirectLogoutState(),
|
||||||
|
hideInvitesAvatars: Boolean = false,
|
||||||
eventSink: (RoomListEvents) -> Unit = {}
|
eventSink: (RoomListEvents) -> Unit = {}
|
||||||
) = RoomListState(
|
) = RoomListState(
|
||||||
matrixUser = matrixUser,
|
matrixUser = matrixUser,
|
||||||
|
|
@ -75,6 +76,7 @@ internal fun aRoomListState(
|
||||||
contentState = contentState,
|
contentState = contentState,
|
||||||
acceptDeclineInviteState = acceptDeclineInviteState,
|
acceptDeclineInviteState = acceptDeclineInviteState,
|
||||||
directLogoutState = directLogoutState,
|
directLogoutState = directLogoutState,
|
||||||
|
hideInvitesAvatars = hideInvitesAvatars,
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ fun RoomListView(
|
||||||
RoomListSearchView(
|
RoomListSearchView(
|
||||||
state = state.searchState,
|
state = state.searchState,
|
||||||
eventSink = state.eventSink,
|
eventSink = state.eventSink,
|
||||||
|
hideInvitesAvatars = state.hideInvitesAvatars,
|
||||||
onRoomClick = onRoomClick,
|
onRoomClick = onRoomClick,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.statusBarsPadding()
|
.statusBarsPadding()
|
||||||
|
|
@ -134,6 +135,7 @@ private fun RoomListScaffold(
|
||||||
RoomListContentView(
|
RoomListContentView(
|
||||||
contentState = state.contentState,
|
contentState = state.contentState,
|
||||||
filtersState = state.filtersState,
|
filtersState = state.filtersState,
|
||||||
|
hideInvitesAvatars = state.hideInvitesAvatars,
|
||||||
eventSink = state.eventSink,
|
eventSink = state.eventSink,
|
||||||
onSetUpRecoveryClick = onSetUpRecoveryClick,
|
onSetUpRecoveryClick = onSetUpRecoveryClick,
|
||||||
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
|
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ import kotlinx.collections.immutable.ImmutableList
|
||||||
fun RoomListContentView(
|
fun RoomListContentView(
|
||||||
contentState: RoomListContentState,
|
contentState: RoomListContentState,
|
||||||
filtersState: RoomListFiltersState,
|
filtersState: RoomListFiltersState,
|
||||||
|
hideInvitesAvatars: Boolean,
|
||||||
eventSink: (RoomListEvents) -> Unit,
|
eventSink: (RoomListEvents) -> Unit,
|
||||||
onSetUpRecoveryClick: () -> Unit,
|
onSetUpRecoveryClick: () -> Unit,
|
||||||
onConfirmRecoveryKeyClick: () -> Unit,
|
onConfirmRecoveryKeyClick: () -> Unit,
|
||||||
|
|
@ -86,6 +87,7 @@ fun RoomListContentView(
|
||||||
is RoomListContentState.Rooms -> {
|
is RoomListContentState.Rooms -> {
|
||||||
RoomsView(
|
RoomsView(
|
||||||
state = contentState,
|
state = contentState,
|
||||||
|
hideInvitesAvatars = hideInvitesAvatars,
|
||||||
filtersState = filtersState,
|
filtersState = filtersState,
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
onSetUpRecoveryClick = onSetUpRecoveryClick,
|
onSetUpRecoveryClick = onSetUpRecoveryClick,
|
||||||
|
|
@ -156,6 +158,7 @@ private fun EmptyView(
|
||||||
@Composable
|
@Composable
|
||||||
private fun RoomsView(
|
private fun RoomsView(
|
||||||
state: RoomListContentState.Rooms,
|
state: RoomListContentState.Rooms,
|
||||||
|
hideInvitesAvatars: Boolean,
|
||||||
filtersState: RoomListFiltersState,
|
filtersState: RoomListFiltersState,
|
||||||
eventSink: (RoomListEvents) -> Unit,
|
eventSink: (RoomListEvents) -> Unit,
|
||||||
onSetUpRecoveryClick: () -> Unit,
|
onSetUpRecoveryClick: () -> Unit,
|
||||||
|
|
@ -171,6 +174,7 @@ private fun RoomsView(
|
||||||
} else {
|
} else {
|
||||||
RoomsViewList(
|
RoomsViewList(
|
||||||
state = state,
|
state = state,
|
||||||
|
hideInvitesAvatars = hideInvitesAvatars,
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
onSetUpRecoveryClick = onSetUpRecoveryClick,
|
onSetUpRecoveryClick = onSetUpRecoveryClick,
|
||||||
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
|
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
|
||||||
|
|
@ -183,6 +187,7 @@ private fun RoomsView(
|
||||||
@Composable
|
@Composable
|
||||||
private fun RoomsViewList(
|
private fun RoomsViewList(
|
||||||
state: RoomListContentState.Rooms,
|
state: RoomListContentState.Rooms,
|
||||||
|
hideInvitesAvatars: Boolean,
|
||||||
eventSink: (RoomListEvents) -> Unit,
|
eventSink: (RoomListEvents) -> Unit,
|
||||||
onSetUpRecoveryClick: () -> Unit,
|
onSetUpRecoveryClick: () -> Unit,
|
||||||
onConfirmRecoveryKeyClick: () -> Unit,
|
onConfirmRecoveryKeyClick: () -> Unit,
|
||||||
|
|
@ -240,6 +245,7 @@ private fun RoomsViewList(
|
||||||
) { index, room ->
|
) { index, room ->
|
||||||
RoomSummaryRow(
|
RoomSummaryRow(
|
||||||
room = room,
|
room = room,
|
||||||
|
hideInviteAvatars = hideInvitesAvatars,
|
||||||
isInviteSeen = room.displayType == RoomSummaryDisplayType.INVITE &&
|
isInviteSeen = room.displayType == RoomSummaryDisplayType.INVITE &&
|
||||||
state.seenRoomInvites.contains(room.roomId),
|
state.seenRoomInvites.contains(room.roomId),
|
||||||
onClick = onRoomClick,
|
onClick = onRoomClick,
|
||||||
|
|
@ -303,6 +309,7 @@ internal fun RoomListContentViewPreview(@PreviewParameter(RoomListContentStatePr
|
||||||
filtersState = aRoomListFiltersState(
|
filtersState = aRoomListFiltersState(
|
||||||
filterSelectionStates = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = true) }
|
filterSelectionStates = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = true) }
|
||||||
),
|
),
|
||||||
|
hideInvitesAvatars = false,
|
||||||
eventSink = {},
|
eventSink = {},
|
||||||
onSetUpRecoveryClick = {},
|
onSetUpRecoveryClick = {},
|
||||||
onConfirmRecoveryKeyClick = {},
|
onConfirmRecoveryKeyClick = {},
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ internal val minHeight = 84.dp
|
||||||
@Composable
|
@Composable
|
||||||
internal fun RoomSummaryRow(
|
internal fun RoomSummaryRow(
|
||||||
room: RoomListRoomSummary,
|
room: RoomListRoomSummary,
|
||||||
|
hideInviteAvatars: Boolean,
|
||||||
isInviteSeen: Boolean,
|
isInviteSeen: Boolean,
|
||||||
onClick: (RoomListRoomSummary) -> Unit,
|
onClick: (RoomListRoomSummary) -> Unit,
|
||||||
eventSink: (RoomListEvents) -> Unit,
|
eventSink: (RoomListEvents) -> Unit,
|
||||||
|
|
@ -81,6 +82,7 @@ internal fun RoomSummaryRow(
|
||||||
RoomSummaryDisplayType.INVITE -> {
|
RoomSummaryDisplayType.INVITE -> {
|
||||||
RoomSummaryScaffoldRow(
|
RoomSummaryScaffoldRow(
|
||||||
room = room,
|
room = room,
|
||||||
|
hideAvatarImage = hideInviteAvatars,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = {
|
onLongClick = {
|
||||||
Timber.d("Long click on invite room")
|
Timber.d("Long click on invite room")
|
||||||
|
|
@ -93,6 +95,7 @@ internal fun RoomSummaryRow(
|
||||||
InviteSenderView(
|
InviteSenderView(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
inviteSender = room.inviteSender,
|
inviteSender = room.inviteSender,
|
||||||
|
hideAvatarImage = hideInviteAvatars
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
@ -165,6 +168,7 @@ private fun RoomSummaryScaffoldRow(
|
||||||
onClick: (RoomListRoomSummary) -> Unit,
|
onClick: (RoomListRoomSummary) -> Unit,
|
||||||
onLongClick: (RoomListRoomSummary) -> Unit,
|
onLongClick: (RoomListRoomSummary) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
hideAvatarImage: Boolean = false,
|
||||||
content: @Composable ColumnScope.() -> Unit
|
content: @Composable ColumnScope.() -> Unit
|
||||||
) {
|
) {
|
||||||
val clickModifier = Modifier.combinedClickable(
|
val clickModifier = Modifier.combinedClickable(
|
||||||
|
|
@ -185,6 +189,7 @@ private fun RoomSummaryScaffoldRow(
|
||||||
CompositeAvatar(
|
CompositeAvatar(
|
||||||
avatarData = room.avatarData,
|
avatarData = room.avatarData,
|
||||||
heroes = room.heroes,
|
heroes = room.heroes,
|
||||||
|
hideAvatarImages = hideAvatarImage,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -388,6 +393,7 @@ private fun MentionIndicatorAtom() {
|
||||||
internal fun RoomSummaryRowPreview(@PreviewParameter(RoomListRoomSummaryProvider::class) data: RoomListRoomSummary) = ElementPreview {
|
internal fun RoomSummaryRowPreview(@PreviewParameter(RoomListRoomSummaryProvider::class) data: RoomListRoomSummary) = ElementPreview {
|
||||||
RoomSummaryRow(
|
RoomSummaryRow(
|
||||||
room = data,
|
room = data,
|
||||||
|
hideInviteAvatars = false,
|
||||||
// Set isInviteSeen to true for the preview when the room has name "Bob"
|
// Set isInviteSeen to true for the preview when the room has name "Bob"
|
||||||
isInviteSeen = data.name == "Bob",
|
isInviteSeen = data.name == "Bob",
|
||||||
onClick = {},
|
onClick = {},
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
@Composable
|
@Composable
|
||||||
internal fun RoomListSearchView(
|
internal fun RoomListSearchView(
|
||||||
state: RoomListSearchState,
|
state: RoomListSearchState,
|
||||||
|
hideInvitesAvatars: Boolean,
|
||||||
eventSink: (RoomListEvents) -> Unit,
|
eventSink: (RoomListEvents) -> Unit,
|
||||||
onRoomClick: (RoomId) -> Unit,
|
onRoomClick: (RoomId) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
@ -80,6 +81,7 @@ internal fun RoomListSearchView(
|
||||||
if (state.isSearchActive) {
|
if (state.isSearchActive) {
|
||||||
RoomListSearchContent(
|
RoomListSearchContent(
|
||||||
state = state,
|
state = state,
|
||||||
|
hideInvitesAvatars = hideInvitesAvatars,
|
||||||
onRoomClick = onRoomClick,
|
onRoomClick = onRoomClick,
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
)
|
)
|
||||||
|
|
@ -92,6 +94,7 @@ internal fun RoomListSearchView(
|
||||||
@Composable
|
@Composable
|
||||||
private fun RoomListSearchContent(
|
private fun RoomListSearchContent(
|
||||||
state: RoomListSearchState,
|
state: RoomListSearchState,
|
||||||
|
hideInvitesAvatars: Boolean,
|
||||||
eventSink: (RoomListEvents) -> Unit,
|
eventSink: (RoomListEvents) -> Unit,
|
||||||
onRoomClick: (RoomId) -> Unit,
|
onRoomClick: (RoomId) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
|
@ -173,6 +176,7 @@ private fun RoomListSearchContent(
|
||||||
) { room ->
|
) { room ->
|
||||||
RoomSummaryRow(
|
RoomSummaryRow(
|
||||||
room = room,
|
room = room,
|
||||||
|
hideInviteAvatars = hideInvitesAvatars,
|
||||||
// TODO
|
// TODO
|
||||||
isInviteSeen = false,
|
isInviteSeen = false,
|
||||||
onClick = ::onRoomClick,
|
onClick = ::onRoomClick,
|
||||||
|
|
@ -189,6 +193,7 @@ private fun RoomListSearchContent(
|
||||||
internal fun RoomListSearchContentPreview(@PreviewParameter(RoomListSearchStateProvider::class) state: RoomListSearchState) = ElementPreview {
|
internal fun RoomListSearchContentPreview(@PreviewParameter(RoomListSearchStateProvider::class) state: RoomListSearchState) = ElementPreview {
|
||||||
RoomListSearchContent(
|
RoomListSearchContent(
|
||||||
state = state,
|
state = state,
|
||||||
|
hideInvitesAvatars = false,
|
||||||
onRoomClick = {},
|
onRoomClick = {},
|
||||||
eventSink = {},
|
eventSink = {},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -48,11 +48,13 @@ fun Avatar(
|
||||||
contentDescription: String? = null,
|
contentDescription: String? = null,
|
||||||
// If not null, will be used instead of the size from avatarData
|
// If not null, will be used instead of the size from avatarData
|
||||||
forcedAvatarSize: Dp? = null,
|
forcedAvatarSize: Dp? = null,
|
||||||
|
// If true, will show initials even if avatarData.url is not null
|
||||||
|
hideImage: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val commonModifier = modifier
|
val commonModifier = modifier
|
||||||
.size(forcedAvatarSize ?: avatarData.size.dp)
|
.size(forcedAvatarSize ?: avatarData.size.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
if (avatarData.url.isNullOrBlank()) {
|
if (avatarData.url.isNullOrBlank() || hideImage) {
|
||||||
InitialsAvatar(
|
InitialsAvatar(
|
||||||
avatarData = avatarData,
|
avatarData = avatarData,
|
||||||
forcedAvatarSize = forcedAvatarSize,
|
forcedAvatarSize = forcedAvatarSize,
|
||||||
|
|
|
||||||
|
|
@ -33,10 +33,16 @@ fun CompositeAvatar(
|
||||||
avatarData: AvatarData,
|
avatarData: AvatarData,
|
||||||
heroes: ImmutableList<AvatarData>,
|
heroes: ImmutableList<AvatarData>,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
hideAvatarImages: Boolean = false,
|
||||||
contentDescription: String? = null,
|
contentDescription: String? = null,
|
||||||
) {
|
) {
|
||||||
if (avatarData.url != null || heroes.isEmpty()) {
|
if (avatarData.url != null || heroes.isEmpty()) {
|
||||||
Avatar(avatarData, modifier, contentDescription)
|
Avatar(
|
||||||
|
avatarData = avatarData,
|
||||||
|
modifier = modifier,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
hideImage = hideAvatarImages
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
val limitedHeroes = heroes.take(4)
|
val limitedHeroes = heroes.take(4)
|
||||||
val numberOfHeroes = limitedHeroes.size
|
val numberOfHeroes = limitedHeroes.size
|
||||||
|
|
@ -49,7 +55,12 @@ fun CompositeAvatar(
|
||||||
error("Unsupported number of heroes: 0")
|
error("Unsupported number of heroes: 0")
|
||||||
}
|
}
|
||||||
1 -> {
|
1 -> {
|
||||||
Avatar(heroes[0], modifier, contentDescription)
|
Avatar(
|
||||||
|
avatarData = heroes[0],
|
||||||
|
modifier = modifier,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
hideImage = hideAvatarImages
|
||||||
|
)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
val angle = 2 * Math.PI / numberOfHeroes
|
val angle = 2 * Math.PI / numberOfHeroes
|
||||||
|
|
@ -91,8 +102,9 @@ fun CompositeAvatar(
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Avatar(
|
Avatar(
|
||||||
heroAvatar,
|
avatarData = heroAvatar,
|
||||||
forcedAvatarSize = heroAvatarSize,
|
forcedAvatarSize = heroAvatarSize,
|
||||||
|
hideImage = hideAvatarImages,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaPreviewValue.Off
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaPreviewValue.On
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaPreviewValue.Private
|
||||||
|
import io.element.android.libraries.matrix.api.room.join.JoinRule
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
|
||||||
|
fun MediaPreviewValue.isPreviewEnabled(joinRule: JoinRule?): Boolean {
|
||||||
|
return when (this) {
|
||||||
|
On -> true
|
||||||
|
Off -> false
|
||||||
|
Private -> when (joinRule) {
|
||||||
|
is JoinRule.Knock,
|
||||||
|
is JoinRule.Invite,
|
||||||
|
is JoinRule.Restricted,
|
||||||
|
is JoinRule.KnockRestricted -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,14 +27,15 @@ import io.element.android.libraries.matrix.ui.model.InviteSender
|
||||||
@Composable
|
@Composable
|
||||||
fun InviteSenderView(
|
fun InviteSenderView(
|
||||||
inviteSender: InviteSender,
|
inviteSender: InviteSender,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
|
hideAvatarImage: Boolean = false,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.padding(vertical = 2.dp)) {
|
Box(modifier = Modifier.padding(vertical = 2.dp)) {
|
||||||
Avatar(avatarData = inviteSender.avatarData)
|
Avatar(avatarData = inviteSender.avatarData, hideImage = hideAvatarImage)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = inviteSender.annotatedString(),
|
text = inviteSender.annotatedString(),
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,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
|
||||||
|
|
@ -305,7 +306,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) {
|
||||||
|
|
@ -332,7 +333,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) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:926ce46cacac7beaac403e1c4e8034398d91dc48ab5f651bc7f0639ac65fe33a
|
||||||
|
size 46759
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:84c2474bc4d10e1aa3184c41b12b1bcf51d7e2b7ec801944608613aa46f50eba
|
||||||
|
size 46636
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:611d435a2b3e6e0f8c7721905f635c1c93aa217df3fe4f4b6bb38bf5700e3629
|
||||||
|
size 34586
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:4d9b0a865db8f87d6c1c95b8fb6d916f261d44cecec52fdebd56288f2df35f0f
|
||||||
|
size 46627
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:0505bdd3cfccf156249ab27ed0f087bd4d01d11121cb1f5e0c8450c6b7b58059
|
||||||
|
size 46615
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:a004afcec0cb40f82172c7abdc4404d1a0d879d0e23c63d0dcd1d7325cddff43
|
||||||
|
size 46471
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:cb09af4f9c96afb60b917d9484e28bacb73c494f9dfc2f42abb482896027b7b7
|
||||||
|
size 46767
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:4dfb5f83a129332a84f0184ba6a37ba9a95ded6fd9692a6e5710aaeb6b424c7c
|
||||||
|
size 48610
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:903d0aa3faa5b4feae990139c467a5a6662808ffb768c7a2a5cd314f9ea09ba2
|
||||||
|
size 48482
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:4a2538700b5dc993568575e7017fc26c3cba7af7b7567fac069af5aa0c6f6b5f
|
||||||
|
size 36484
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9177ba1065f5cfe3137a16c024445332b2fe326969f5f6d65ccf6b1707deabe9
|
||||||
|
size 48502
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:1f07064f41aafea6a91e3314d7d3bb099918e348ef9f9154a057d90758b9fb71
|
||||||
|
size 48480
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:92c1c93890214c88ed0e4d1d9faee27cc671db94fe928633ccc82471c137b64a
|
||||||
|
size 48422
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:7f21d860514e97b48c3fab7fef4fbf6d710854d21ea2eecc3b44a5ba340c92eb
|
||||||
|
size 48608
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:c8f664f4a07b88fef329728a9f1ce3151a3b36b0255bcb62d4bc653a6d62b5a4
|
|
||||||
size 54746
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:9471766f40c07f65aba37fb166f99c8821995e192d9fa2fe633a84dd8dc4f4ac
|
|
||||||
size 54511
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:d36aea49da81fc526b91bdd5b30fdaa974bf6ffc498fde32f68182954f3d6b44
|
|
||||||
size 31893
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:a76df447d4e5108fc8412eebfcab8037e356f7ed410ad247379399b3d547da11
|
|
||||||
size 54570
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:7560757da0a4a8c9ffe70c4a81b6dc4d4e379db4c664d16fe4d958a27d77026d
|
|
||||||
size 54541
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:8f73399ea5f3126559521931424428af6b6d6de578ba8799a577f59ec628aded
|
|
||||||
size 53554
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:ba80841973fa1447940382975d5b56eff806bcf819441bc8cf25fc9b60c50eb9
|
|
||||||
size 53265
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:a3907566d471c26da9d8c4f1f10fb2cc5d69db584a32f83b662e195f696df320
|
|
||||||
size 29568
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:e7ae58bd327e19f6c7d10c5be512fbd6ec4eeaa716b995dcc0dc081f2ff3c9c3
|
|
||||||
size 53306
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:bb6e24a7ae84d488a7b9a1574ca86d08b134ff889c2f885c7b7e2b1b5044868e
|
|
||||||
size 53270
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:e1cb2f64719f8abda45e50b9cca1a2985630736c35e9cdb32b207696ec067edf
|
oid sha256:7836dc63d1181d2b057df6baf5fe3b8a05298aaa4e23a75a79790cafff1fd451
|
||||||
size 57683
|
size 54048
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:e1cb2f64719f8abda45e50b9cca1a2985630736c35e9cdb32b207696ec067edf
|
oid sha256:7836dc63d1181d2b057df6baf5fe3b8a05298aaa4e23a75a79790cafff1fd451
|
||||||
size 57683
|
size 54048
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:40cdf166be2b920b5e6282835fa80654a734ad19bbefdc2c37d287447cb3b49a
|
oid sha256:cad88329f2179428e72e96499d91ebbfba0647c74fb271bbfffaea0f695d28fb
|
||||||
size 56274
|
size 52595
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:b8ff541c779c046143e109f80fe45c5742868b1b4b9d4d5a96371b336e2f1c56
|
oid sha256:5861efb5c4453365fa215ec675e8c1289d86fa7756b42c57dcd9c7f138cd62bb
|
||||||
size 55837
|
size 52067
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:b8ff541c779c046143e109f80fe45c5742868b1b4b9d4d5a96371b336e2f1c56
|
oid sha256:5861efb5c4453365fa215ec675e8c1289d86fa7756b42c57dcd9c7f138cd62bb
|
||||||
size 55837
|
size 52067
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:d18ec536792e9e3cea6a40e8a2901e131631be7795f92f64726b8b91e8c52e34
|
oid sha256:af268c15499c557a3c6502f6daf732ede68f7fca1539a10a420809ca0fb212a3
|
||||||
size 54433
|
size 50664
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue