From 6533a10ab9cdf19597733c0b312c1528fb6c4c5c Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:19:46 +0300 Subject: [PATCH] Keep manually revealed media visible when re-entering a room The set of revealed event ids lived on TimelineProtectionPresenter, which is scoped to the room graph. That graph is rebuilt every time the room is opened, so the set was empty on re-entry and every revealed image was hidden again. Move it into a session scoped TimelineProtectionStore. The store is in memory rather than persisted: revealing media is a privacy control and there is no hide action today, so keeping the reveals for the session leaves restarting the app as a way to undo one, and the set is dropped on logout. --- .../protection/TimelineProtectionPresenter.kt | 9 ++---- .../protection/TimelineProtectionStore.kt | 31 +++++++++++++++++++ .../TimelineProtectionPresenterTest.kt | 20 ++++++++++++ 3 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionStore.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt index 22be4acb2d..6110a2c4db 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt @@ -12,7 +12,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateSetOf import androidx.compose.runtime.remember import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn @@ -20,7 +19,6 @@ import io.element.android.features.contentscanner.api.ContentScannerService import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.di.RoomScope -import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.media.MediaPreviewService import io.element.android.libraries.matrix.api.media.isPreviewEnabled import io.element.android.libraries.matrix.api.room.BaseRoom @@ -32,9 +30,8 @@ class TimelineProtectionPresenter( private val mediaPreviewService: MediaPreviewService, private val room: BaseRoom, private val contentScannerService: ContentScannerService, + private val timelineProtectionStore: TimelineProtectionStore, ) : Presenter { - private val allowedEvents = mutableStateSetOf() - @Composable override fun present(): TimelineProtectionState { val mediaPreviewValue = remember { @@ -48,7 +45,7 @@ class TimelineProtectionPresenter( if (isPreviewEnabled) { ProtectionState.RenderAll } else { - ProtectionState.RenderOnly(eventIds = allowedEvents.toImmutableSet()) + ProtectionState.RenderOnly(eventIds = timelineProtectionStore.allowedEventIds.toImmutableSet()) } } } @@ -56,7 +53,7 @@ class TimelineProtectionPresenter( fun handleEvent(event: TimelineProtectionEvent) { when (event) { is TimelineProtectionEvent.ShowContent -> { - allowedEvents += setOfNotNull(event.eventId) + event.eventId?.let(timelineProtectionStore::allowEvent) } is TimelineProtectionEvent.ValidateContent -> { contentScannerService.scan(event.mediaSources, event.validationState) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionStore.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionStore.kt new file mode 100644 index 0000000000..fe0c753fac --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionStore.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.protection + +import androidx.compose.runtime.mutableStateSetOf +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.EventId + +interface TimelineProtectionStore { + val allowedEventIds: Set + fun allowEvent(eventId: EventId) +} + +@ContributesBinding(SessionScope::class) +@SingleIn(SessionScope::class) +class DefaultTimelineProtectionStore : TimelineProtectionStore { + private val allowedEvents = mutableStateSetOf() + + override val allowedEventIds: Set = allowedEvents + + override fun allowEvent(eventId: EventId) { + allowedEvents += eventId + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt index 11142cc9a2..161b350c96 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt @@ -95,6 +95,24 @@ class TimelineProtectionPresenterTest { } } + @Test + fun `present - shown content is restored when the presenter is recreated`() = runTest { + val mediaPreviewConfig = MediaPreviewConfig(mediaPreviewValue = MediaPreviewValue.Off, hideInviteAvatar = false) + val mediaPreviewService = FakeMediaPreviewService(mediaPreviewConfigFlow = MutableStateFlow(mediaPreviewConfig)) + val timelineProtectionStore = DefaultTimelineProtectionStore() + createPresenter(mediaPreviewService = mediaPreviewService, timelineProtectionStore = timelineProtectionStore).test { + val initialState = awaitItem() + assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf())) + initialState.eventSink(TimelineProtectionEvent.ShowContent(eventId = AN_EVENT_ID)) + val finalState = awaitItem() + assertThat(finalState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf(AN_EVENT_ID))) + } + createPresenter(mediaPreviewService = mediaPreviewService, timelineProtectionStore = timelineProtectionStore).test { + val initialState = awaitItem() + assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf(AN_EVENT_ID))) + } + } + @OptIn(ExperimentalCoroutinesApi::class) @Test fun `present - validate media scans the media source`() = runTest { @@ -134,9 +152,11 @@ class TimelineProtectionPresenterTest { room: BaseRoom = FakeBaseRoom(), mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(), contentScannerService: ContentScannerService = ContentScannerService { _: List, _: ContentValidationState -> }, + timelineProtectionStore: TimelineProtectionStore = DefaultTimelineProtectionStore(), ) = TimelineProtectionPresenter( mediaPreviewService = mediaPreviewService, room = room, contentScannerService = contentScannerService, + timelineProtectionStore = timelineProtectionStore, ) }