Keep the opened image on screen when the media gallery fails to load

Opening an image from the timeline replaces it with a full-screen page
reading "error". Two things go wrong. The viewer already holds the item
that was tapped, but building the surrounding media timeline can fail and
the failure overwrites that cached item, so the picture the user asked for
disappears. And the failure page renders the exception message straight
from the SDK, which is developer text with nothing actionable in it.

Emit the failure only when there is nothing cached, so a viewer opened on
a specific event keeps showing that event and stays usable while the rest
of the gallery is unavailable. Where there really is nothing to show, the
page now uses the same alert icon, title and message as the other error
states in this screen, built from strings that already exist.

The trade-off is that the timeline error is no longer surfaced in the
cache case; it is still logged. Swapping it for a snackbar would need a
new surface in the viewer and is left out.
This commit is contained in:
hayaksi1
2026-08-09 13:52:55 +03:00
parent 044737a3a7
commit baf28f85cf
3 changed files with 36 additions and 8 deletions
@@ -83,7 +83,9 @@ class TimelineMediaGalleryDataSource(
},
{
Timber.e(it, "Failed to get media timeline for room ${room.roomId}")
groupedMediaItemsFlow.emit(AsyncData.Failure(it))
if (cache == null) {
groupedMediaItemsFlow.emit(AsyncData.Failure(it))
}
},
)
}.flatMapLatest { timeline ->
@@ -82,7 +82,6 @@ import io.element.android.libraries.audio.api.AudioFocus
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.async.AsyncFailure
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.components.async.AsyncLoading
import io.element.android.libraries.designsystem.components.button.BackButton
@@ -209,7 +208,6 @@ fun MediaViewerView(
when (val dataForPage = state.listData[page]) {
is MediaViewerPageData.Failure -> {
MediaViewerErrorPage(
throwable = dataForPage.throwable,
onDismiss = onBackClick,
)
}
@@ -506,7 +504,6 @@ private fun MediaViewerLoadingPage(
@Composable
private fun MediaViewerErrorPage(
throwable: Throwable,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -520,9 +517,10 @@ private fun MediaViewerErrorPage(
.navigationBarsPadding(),
contentAlignment = Alignment.Center
) {
AsyncFailure(
throwable = throwable,
onRetry = null
IconTitleSubtitleMolecule(
iconStyle = BigIcon.Style.AlertSolid,
title = stringResource(CommonStrings.common_something_went_wrong),
subTitle = stringResource(CommonStrings.common_something_went_wrong_message),
)
}
}
@@ -39,6 +39,7 @@ import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventC
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
@@ -171,6 +172,32 @@ class TimelineMediaGalleryDataSourceTest {
}
}
@Test
fun `test - failing to load a focused timeline keeps the item that was opened`() = runTest {
val room = FakeJoinedRoom(
createTimelineResult = { Result.failure(AN_EXCEPTION) },
roomCoroutineScope = backgroundScope,
)
val initialMediaItem = aMediaItemImage()
val sut = createTimelineMediaGalleryDataSource(
room = room,
mediaTimeline = FocusedMediaTimeline(
room = room,
eventId = AN_EVENT_ID,
onlyPinnedEvents = false,
initialMediaItem = initialMediaItem,
),
)
sut.start(backgroundScope)
sut.groupedMediaItemsFlow().test {
val item = awaitItem()
assertThat(item.isSuccess()).isTrue()
assertThat(item.dataOrNull()?.imageAndVideoItems).contains(initialMediaItem)
expectNoEvents()
}
assertThat(sut.getLastData().isFailure()).isFalse()
}
@Test
fun `test - when timeline emits new data, the flow emits the data`() = runTest {
val timelineItems = MutableStateFlow<List<MatrixTimelineItem>>(emptyList())
@@ -269,10 +296,11 @@ internal fun TestScope.createTimelineMediaGalleryDataSource(
room: JoinedRoom = FakeJoinedRoom(
liveTimeline = FakeTimeline(),
),
mediaTimeline: MediaTimeline? = null,
): TimelineMediaGalleryDataSource {
return TimelineMediaGalleryDataSource(
room = room,
mediaTimeline = LiveMediaTimeline(room),
mediaTimeline = mediaTimeline ?: LiveMediaTimeline(room),
timelineMediaItemsFactory = createTimelineMediaItemsFactory(),
mediaItemsPostProcessor = MediaItemsPostProcessor(),
)