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 1/3] 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, ) } From 09efb04a8f0451ffc031e6abe6042522d6ef1ef2 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:59:22 +0300 Subject: [PATCH 2/3] Hold the revealed event ids in a plain set The store no longer needs a mutableStateSetOf: an ordinary Set kept in a mutableStateOf does the same job, and matches DefaultMediaPreviewConfigStateStore. The Compose state itself has to stay. TimelineProtectionPresenter derives ProtectionState from the store inside a derivedStateOf, so the set must be a snapshot object for a reveal to invalidate it and reach the timeline. Backing it with a plain mutableSetOf leaves the presenter with nothing to observe and it stops emitting after ShowContent. --- .../impl/timeline/protection/TimelineProtectionStore.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 index fe0c753fac..019f4eabd6 100644 --- 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 @@ -7,7 +7,7 @@ package io.element.android.features.messages.impl.timeline.protection -import androidx.compose.runtime.mutableStateSetOf +import androidx.compose.runtime.mutableStateOf import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn import io.element.android.libraries.di.SessionScope @@ -21,11 +21,11 @@ interface TimelineProtectionStore { @ContributesBinding(SessionScope::class) @SingleIn(SessionScope::class) class DefaultTimelineProtectionStore : TimelineProtectionStore { - private val allowedEvents = mutableStateSetOf() + private val allowedEvents = mutableStateOf(emptySet()) - override val allowedEventIds: Set = allowedEvents + override val allowedEventIds: Set get() = allowedEvents.value override fun allowEvent(eventId: EventId) { - allowedEvents += eventId + allowedEvents.value = allowedEvents.value + eventId } } From 1b833df7270701f1f1913890d5b6409823777734 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:36:36 +0300 Subject: [PATCH 3/3] Expose the revealed event ids as a SnapshotStateSet Restore the snapshot backed set the store started with and declare allowedEventIds as SnapshotStateSet so the interface says out loud that reads of it are observable. TimelineProtectionPresenter reads the set inside derivedStateOf, so a plain set silently stops the timeline from re-rendering when an event is revealed. Naming the type keeps that requirement from being mistaken for an implementation detail. --- .../timeline/protection/TimelineProtectionStore.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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 index 019f4eabd6..aa6655cb86 100644 --- 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 @@ -7,25 +7,26 @@ package io.element.android.features.messages.impl.timeline.protection -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableStateSetOf +import androidx.compose.runtime.snapshots.SnapshotStateSet 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 + val allowedEventIds: SnapshotStateSet fun allowEvent(eventId: EventId) } @ContributesBinding(SessionScope::class) @SingleIn(SessionScope::class) class DefaultTimelineProtectionStore : TimelineProtectionStore { - private val allowedEvents = mutableStateOf(emptySet()) + private val allowedEvents = mutableStateSetOf() - override val allowedEventIds: Set get() = allowedEvents.value + override val allowedEventIds: SnapshotStateSet = allowedEvents override fun allowEvent(eventId: EventId) { - allowedEvents.value = allowedEvents.value + eventId + allowedEvents += eventId } }