diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index f9d9bbf27f..6b84423d44 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -123,6 +123,7 @@ class MessagesFlowNode @AssistedInject constructor( @Parcelize data class MediaViewer( + val mode: MediaViewerEntryPoint.MediaViewerMode, val eventId: EventId?, val mediaInfo: MediaInfo, val mediaSource: MediaSource, @@ -248,8 +249,7 @@ class MessagesFlowNode @AssistedInject constructor( } is NavTarget.MediaViewer -> { val params = MediaViewerEntryPoint.Params( - // TODO When we will be able to load a media timeline from a EventId, change mode here (and use a mixed mode?) - mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia, + mode = navTarget.mode, eventId = navTarget.eventId, mediaInfo = navTarget.mediaInfo, mediaSource = navTarget.mediaSource, @@ -362,6 +362,7 @@ class MessagesFlowNode @AssistedInject constructor( val navTarget = when (event.content) { is TimelineItemImageContent -> { buildMediaViewerNavTarget( + mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos, event = event, content = event.content, mediaSource = event.content.mediaSource, @@ -373,6 +374,7 @@ class MessagesFlowNode @AssistedInject constructor( if encrypted on certain bridges */ event.content.preferredMediaSource?.let { preferredMediaSource -> buildMediaViewerNavTarget( + mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos, event = event, content = event.content, mediaSource = preferredMediaSource, @@ -382,6 +384,7 @@ class MessagesFlowNode @AssistedInject constructor( } is TimelineItemVideoContent -> { buildMediaViewerNavTarget( + mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos, event = event, content = event.content, mediaSource = event.content.mediaSource, @@ -390,6 +393,7 @@ class MessagesFlowNode @AssistedInject constructor( } is TimelineItemFileContent -> { buildMediaViewerNavTarget( + mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios, event = event, content = event.content, mediaSource = event.content.mediaSource, @@ -398,6 +402,7 @@ class MessagesFlowNode @AssistedInject constructor( } is TimelineItemAudioContent -> { buildMediaViewerNavTarget( + mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios, event = event, content = event.content, mediaSource = event.content.mediaSource, @@ -426,12 +431,14 @@ class MessagesFlowNode @AssistedInject constructor( } private fun buildMediaViewerNavTarget( + mode: MediaViewerEntryPoint.MediaViewerMode, event: TimelineItem.Event, content: TimelineItemEventContentWithAttachment, mediaSource: MediaSource, thumbnailSource: MediaSource?, ): NavTarget { return NavTarget.MediaViewer( + mode = mode, eventId = event.eventId, mediaInfo = MediaInfo( filename = content.filename, diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt index bb86684be0..4d5f8c7a19 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt @@ -118,8 +118,9 @@ interface MatrixRoom : Closeable { /** * Create a new timeline for the media events of the room. + * @param eventId The event to focus on, if any. */ - suspend fun mediaTimeline(): Result + suspend fun mediaTimeline(eventId: EventId?): Result fun destroy() diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index ab16ac383f..911a62f376 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -253,11 +253,21 @@ class RustMatrixRoom( } } - override suspend fun mediaTimeline(): Result = withContext(roomDispatcher) { + override suspend fun mediaTimeline( + eventId: EventId?, + ): Result = withContext(roomDispatcher) { + val focus = if (eventId != null) { + TimelineFocus.Event( + eventId = eventId.value, + numContextEvents = 50u, + ) + } else { + TimelineFocus.Live + } runCatching { innerRoom.timelineWithConfiguration( configuration = TimelineConfiguration( - focus = TimelineFocus.Live, + focus = focus, allowedMessageTypes = AllowedMessageTypes.Only( types = listOf( RoomMessageEventMessageType.FILE, @@ -270,7 +280,7 @@ class RustMatrixRoom( dateDividerMode = DateDividerMode.MONTHLY, ) ).let { inner -> - createTimeline(inner, mode = Timeline.Mode.MEDIA) + createTimeline(inner, mode = if (eventId != null) Timeline.Mode.FOCUSED_ON_EVENT else Timeline.Mode.MEDIA) } }.onFailure { if (it is CancellationException) { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index 62d19bd66c..1fd95da36b 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -137,7 +137,7 @@ class FakeMatrixRoom( private val getMembersResult: (Int) -> Result> = { lambdaError() }, private val timelineFocusedOnEventResult: (EventId) -> Result = { lambdaError() }, private val pinnedEventsTimelineResult: () -> Result = { lambdaError() }, - private val mediaTimelineResult: () -> Result = { lambdaError() }, + private val mediaTimelineResult: (EventId?) -> Result = { lambdaError() }, private val setSendQueueEnabledLambda: (Boolean) -> Unit = { _: Boolean -> }, private val saveComposerDraftLambda: (ComposerDraft) -> Result = { _: ComposerDraft -> Result.success(Unit) }, private val loadComposerDraftLambda: () -> Result = { Result.success(null) }, @@ -215,8 +215,8 @@ class FakeMatrixRoom( pinnedEventsTimelineResult() } - override suspend fun mediaTimeline(): Result = simulateLongTask { - mediaTimelineResult() + override suspend fun mediaTimeline(eventId: EventId?): Result = simulateLongTask { + mediaTimelineResult(eventId) } override suspend fun subscribeToSync() { diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt index a824fc5540..bbcd931410 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt @@ -39,6 +39,7 @@ interface MediaViewerEntryPoint : FeatureEntryPoint { val canShowInfo: Boolean, ) : NodeInputs + // TODO convert to sealed class and add eventId to the 2nd and 3rd items enum class MediaViewerMode { SingleMedia, TimelineImagesAndVideos, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FocusedTimelineMediaGalleryDataSourceFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FocusedTimelineMediaGalleryDataSourceFactory.kt new file mode 100644 index 0000000000..a8a4d0380d --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FocusedTimelineMediaGalleryDataSourceFactory.kt @@ -0,0 +1,44 @@ +/* + * 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.mediaviewer.impl.gallery + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.room.MatrixRoom +import javax.inject.Inject + +interface FocusedTimelineMediaGalleryDataSourceFactory { + fun createFor( + eventId: EventId, + mediaItem: MediaItem.Event, + ): MediaGalleryDataSource +} + +@ContributesBinding(RoomScope::class) +class DefaultFocusedTimelineMediaGalleryDataSourceFactory @Inject constructor( + private val room: MatrixRoom, + private val timelineMediaItemsFactory: TimelineMediaItemsFactory, + private val mediaItemsPostProcessor: MediaItemsPostProcessor, +) : FocusedTimelineMediaGalleryDataSourceFactory { + override fun createFor( + eventId: EventId, + mediaItem: MediaItem.Event, + ): MediaGalleryDataSource { + return TimelineMediaGalleryDataSource( + room = room, + mediaTimeline = FocusedMediaTimeline( + room = room, + eventId = eventId, + initialMediaItem = mediaItem, + ), + timelineMediaItemsFactory = timelineMediaItemsFactory, + mediaItemsPostProcessor = mediaItemsPostProcessor, + ) + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryDataSource.kt index 0e12653108..204f140eda 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryDataSource.kt @@ -39,6 +39,7 @@ interface MediaGalleryDataSource { @ContributesBinding(RoomScope::class) class TimelineMediaGalleryDataSource @Inject constructor( private val room: MatrixRoom, + private val mediaTimeline: MediaTimeline, private val timelineMediaItemsFactory: TimelineMediaItemsFactory, private val mediaItemsPostProcessor: MediaItemsPostProcessor, ) : MediaGalleryDataSource { @@ -48,7 +49,9 @@ class TimelineMediaGalleryDataSource @Inject constructor( override fun groupedMediaItemsFlow(): Flow> = groupedMediaItemsFlow - override fun getLastData(): AsyncData = groupedMediaItemsFlow.replayCache.firstOrNull() ?: AsyncData.Uninitialized + override fun getLastData(): AsyncData = groupedMediaItemsFlow.replayCache.firstOrNull() + ?: mediaTimeline.getCache()?.let { AsyncData.Success(it) } + ?: AsyncData.Uninitialized private val isStarted = AtomicBoolean(false) @@ -58,8 +61,13 @@ class TimelineMediaGalleryDataSource @Inject constructor( return } flow { - groupedMediaItemsFlow.emit(AsyncData.Loading()) - room.mediaTimeline().fold( + val cache = mediaTimeline.getCache() + if (cache != null) { + groupedMediaItemsFlow.emit(AsyncData.Success(cache)) + } else { + groupedMediaItemsFlow.emit(AsyncData.Loading()) + } + mediaTimeline.getTimeline().fold( { timeline = it emit(it) @@ -78,6 +86,8 @@ class TimelineMediaGalleryDataSource @Inject constructor( timelineMediaItemsFactory.timelineItems }.map { timelineItems -> mediaItemsPostProcessor.process(mediaItems = timelineItems) + }.map { + mediaTimeline.orCache(it) }.onEach { groupedMediaItems -> groupedMediaItemsFlow.emit(AsyncData.Success(groupedMediaItems)) } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt index 3f5e1fc107..3eb5281c43 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt @@ -108,15 +108,15 @@ fun MediaGalleryView( ) { paddingValues -> Column( modifier = Modifier - .padding(paddingValues) - .consumeWindowInsets(paddingValues) - .fillMaxSize(), + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + .fillMaxSize(), verticalArrangement = Arrangement.spacedBy(2.dp), ) { SingleChoiceSegmentedButtonRow( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), + .fillMaxWidth() + .padding(horizontal = 16.dp), ) { MediaGalleryMode.entries.forEach { mode -> SegmentedButton( @@ -354,8 +354,8 @@ private fun MediaGalleryImageGrid( ) { LazyVerticalGrid( modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp), + .fillMaxSize() + .padding(horizontal = 16.dp), columns = GridCells.Adaptive(80.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp), @@ -426,9 +426,9 @@ private fun LoadingMoreIndicator( Timeline.PaginationDirection.FORWARDS -> { LinearProgressIndicator( modifier = Modifier - .fillMaxWidth() - .padding(top = 2.dp) - .height(1.dp) + .fillMaxWidth() + .padding(top = 2.dp) + .height(1.dp) ) } Timeline.PaginationDirection.BACKWARDS -> { @@ -440,7 +440,10 @@ private fun LoadingMoreIndicator( } val latestEventSink by rememberUpdatedState(eventSink) LaunchedEffect(item.timestamp) { - latestEventSink(MediaGalleryEvents.LoadMore(item.direction)) + // TODO Add isFake to the model instead of using -1 for timestamp + if (item.timestamp != -1L) { + latestEventSink(MediaGalleryEvents.LoadMore(item.direction)) + } } } } @@ -466,9 +469,9 @@ private fun EmptyContent( OnboardingBackground() PageTitle( modifier = Modifier - .fillMaxWidth() - .padding(top = 44.dp) - .padding(24.dp), + .fillMaxWidth() + .padding(top = 44.dp) + .padding(24.dp), title = stringResource(titleRes), iconStyle = BigIcon.Style.Default(icon), subtitle = stringResource(subtitleRes), @@ -486,9 +489,9 @@ private fun LoadingContent( OnboardingBackground() Column( modifier = Modifier - .fillMaxSize() - .padding(top = 48.dp) - .padding(24.dp), + .fillMaxSize() + .padding(top = 48.dp) + .padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaTimeline.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaTimeline.kt new file mode 100644 index 0000000000..521f585202 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaTimeline.kt @@ -0,0 +1,115 @@ +/* + * 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.mediaviewer.impl.gallery + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.timeline.Timeline +import kotlinx.collections.immutable.persistentListOf +import javax.inject.Inject + +interface MediaTimeline { + suspend fun getTimeline(): Result + fun getCache(): GroupedMediaItems? + fun orCache(data: GroupedMediaItems): GroupedMediaItems +} + +/** + * A timeline holder that can be used by the gallery and the media viewer. + * When opening the Media Viewer, if the held timeline knows the Event, it will + * be used, else a FocusedMediaTimeline will be used. + */ +@SingleIn(RoomScope::class) +@ContributesBinding(RoomScope::class) +class LiveMediaTimeline @Inject constructor( + private val room: MatrixRoom, +) : MediaTimeline { + private var timeline: Timeline? = null + override suspend fun getTimeline(): Result { + return if (timeline == null) { + room.mediaTimeline(null).fold( + { + timeline = it + Result.success(it) + }, + { + Result.failure(it) + }, + ) + } else { + Result.success(timeline!!) + } + } + + // No cache for LiveMediaTimeline + override fun getCache(): GroupedMediaItems? = null + override fun orCache(data: GroupedMediaItems) = data +} + +/** + * A class that will provide a media timeline that is focused on a particular event. + */ +class FocusedMediaTimeline( + private val room: MatrixRoom, + private val eventId: EventId, + private val initialMediaItem: MediaItem.Event, +) : MediaTimeline { + override suspend fun getTimeline(): Result { + return room.mediaTimeline(eventId) + } + + override fun getCache(): GroupedMediaItems { + // TODO Cleanup + return GroupedMediaItems( + fileItems = persistentListOf( + MediaItem.LoadingIndicator( + id = UniqueId("loading_forwards"), + direction = Timeline.PaginationDirection.FORWARDS, + timestamp = -1L, + ), + initialMediaItem, + MediaItem.LoadingIndicator( + id = UniqueId("loading_backwards"), + direction = Timeline.PaginationDirection.BACKWARDS, + timestamp = -1L, + ), + ), + imageAndVideoItems = persistentListOf( + MediaItem.LoadingIndicator( + id = UniqueId("loading_forwards"), + direction = Timeline.PaginationDirection.FORWARDS, + timestamp = -1L, + ), + initialMediaItem, + MediaItem.LoadingIndicator( + id = UniqueId("loading_backwards"), + direction = Timeline.PaginationDirection.BACKWARDS, + timestamp = -1L, + ), + ), + ) + } + + override fun orCache(data: GroupedMediaItems): GroupedMediaItems { + return if (data.hasEvent(eventId)) { + data + } else { + getCache() + } + } +} + +fun GroupedMediaItems.hasEvent(eventId: EventId): Boolean { + return (fileItems + imageAndVideoItems) + .filterIsInstance() + .any { it.eventId() == eventId } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSource.kt index 8e6b708a7f..91ee02966e 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSource.kt @@ -29,57 +29,57 @@ class SingleMediaGalleryDataSource( companion object { fun createFrom(params: MediaViewerEntryPoint.Params) = SingleMediaGalleryDataSource( - data = when { - params.mediaInfo.mimeType.isMimeTypeImage() -> { - MediaItem.Image( - id = UniqueId("dummy"), - eventId = params.eventId, - mediaInfo = params.mediaInfo, - mediaSource = params.mediaSource, - thumbnailSource = params.thumbnailSource, - ) - } - params.mediaInfo.mimeType.isMimeTypeVideo() -> { - MediaItem.Video( - id = UniqueId("dummy"), - eventId = params.eventId, - mediaInfo = params.mediaInfo, - mediaSource = params.mediaSource, - thumbnailSource = params.thumbnailSource, - ) - } - params.mediaInfo.mimeType.isMimeTypeAudio() -> { - if (params.mediaInfo.waveform == null) { - MediaItem.Audio( - id = UniqueId("dummy"), - eventId = params.eventId, - mediaInfo = params.mediaInfo, - mediaSource = params.mediaSource, - ) - } else { - MediaItem.Voice( - id = UniqueId("dummy"), - eventId = params.eventId, - mediaInfo = params.mediaInfo, - mediaSource = params.mediaSource, - ) - } - } - else -> { - MediaItem.File( - id = UniqueId("dummy"), - eventId = params.eventId, - mediaInfo = params.mediaInfo, - mediaSource = params.mediaSource, - ) - } - }.let { mediaItem -> - GroupedMediaItems( - // Always use imageAndVideoItems, in Single mode, this is the data that will be used - imageAndVideoItems = persistentListOf(mediaItem), - fileItems = persistentListOf(), - ) - } + data = GroupedMediaItems( + // Always use imageAndVideoItems, in Single mode, this is the data that will be used + imageAndVideoItems = persistentListOf(params.toMediaItem()), + fileItems = persistentListOf(), + ) + ) + } +} + +fun MediaViewerEntryPoint.Params.toMediaItem() = when { + mediaInfo.mimeType.isMimeTypeImage() -> { + MediaItem.Image( + id = UniqueId("dummy"), + eventId = eventId, + mediaInfo = mediaInfo, + mediaSource = mediaSource, + thumbnailSource = thumbnailSource, + ) + } + mediaInfo.mimeType.isMimeTypeVideo() -> { + MediaItem.Video( + id = UniqueId("dummy"), + eventId = eventId, + mediaInfo = mediaInfo, + mediaSource = mediaSource, + thumbnailSource = thumbnailSource, + ) + } + mediaInfo.mimeType.isMimeTypeAudio() -> { + if (mediaInfo.waveform == null) { + MediaItem.Audio( + id = UniqueId("dummy"), + eventId = eventId, + mediaInfo = mediaInfo, + mediaSource = mediaSource, + ) + } else { + MediaItem.Voice( + id = UniqueId("dummy"), + eventId = eventId, + mediaInfo = mediaInfo, + mediaSource = mediaSource, + ) + } + } + else -> { + MediaItem.File( + id = UniqueId("dummy"), + eventId = eventId, + mediaInfo = mediaInfo, + mediaSource = mediaSource, ) } } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt index b185834c3e..b69105930d 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt @@ -44,6 +44,7 @@ class MediaViewerDataSource( private val mediaLoader: MatrixMediaLoader, private val localMediaFactory: LocalMediaFactory, private val systemClock: SystemClock, + private val pagerKeysHandler: PagerKeysHandler, ) { // List of media files that are currently being loaded private val mediaFiles: MutableList = mutableListOf() @@ -78,6 +79,7 @@ class MediaViewerDataSource( MediaViewerPageData.Loading( direction = Timeline.PaginationDirection.BACKWARDS, timestamp = systemClock.epochMillis(), + pagerKey = Long.MIN_VALUE, ) ) } @@ -108,7 +110,10 @@ class MediaViewerDataSource( * will be used to render the downloaded media (see [loadMedia] which will update this value). */ private fun buildMediaViewerPageList(groupedItems: List) = buildList { - groupedItems.forEach { mediaItem -> + // Filter out DateSeparator items, we do not need them for the media viewer + val groupedItemsNoDateSeparator = groupedItems.filterNot { it is MediaItem.DateSeparator } + pagerKeysHandler.accept(groupedItemsNoDateSeparator) + groupedItemsNoDateSeparator.forEach { mediaItem -> when (mediaItem) { is MediaItem.DateSeparator -> Unit is MediaItem.Event -> { @@ -123,6 +128,7 @@ class MediaViewerDataSource( mediaSource = mediaItem.mediaSource(), thumbnailSource = mediaItem.thumbnailSource(), downloadedMedia = localMedia, + pagerKey = pagerKeysHandler.getKey(mediaItem), ) ) } @@ -130,6 +136,7 @@ class MediaViewerDataSource( MediaViewerPageData.Loading( direction = mediaItem.direction, timestamp = systemClock.epochMillis(), + pagerKey = pagerKeysHandler.getKey(mediaItem), ) ) } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt index e06b691520..a9df9073ed 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt @@ -24,9 +24,12 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory +import io.element.android.libraries.mediaviewer.impl.gallery.FocusedTimelineMediaGalleryDataSourceFactory import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryMode import io.element.android.libraries.mediaviewer.impl.gallery.SingleMediaGalleryDataSource import io.element.android.libraries.mediaviewer.impl.gallery.TimelineMediaGalleryDataSource +import io.element.android.libraries.mediaviewer.impl.gallery.hasEvent +import io.element.android.libraries.mediaviewer.impl.gallery.toMediaItem import io.element.android.services.toolbox.api.systemclock.SystemClock @ContributesNode(RoomScope::class) @@ -35,10 +38,12 @@ class MediaViewerNode @AssistedInject constructor( @Assisted plugins: List, presenterFactory: MediaViewerPresenter.Factory, timelineMediaGalleryDataSource: TimelineMediaGalleryDataSource, + focusedTimelineMediaGalleryDataSourceFactory: FocusedTimelineMediaGalleryDataSourceFactory, mediaLoader: MatrixMediaLoader, localMediaFactory: LocalMediaFactory, coroutineDispatchers: CoroutineDispatchers, systemClock: SystemClock, + pagerKeysHandler: PagerKeysHandler, ) : Node(buildContext, plugins = plugins), MediaViewerNavigator { private val inputs = inputs() @@ -62,7 +67,23 @@ class MediaViewerNode @AssistedInject constructor( private val mediaGallerySource = if (inputs.mode == MediaViewerEntryPoint.MediaViewerMode.SingleMedia) { SingleMediaGalleryDataSource.createFrom(inputs) } else { - timelineMediaGalleryDataSource + val eventId = inputs.eventId + if (eventId == null) { + // Should not happen + timelineMediaGalleryDataSource + } else { + // Does timelineMediaGalleryDataSource knows the eventId? + val lastData = timelineMediaGalleryDataSource.getLastData().dataOrNull() + val isEventKnown = lastData?.hasEvent(eventId) == true + if (isEventKnown) { + timelineMediaGalleryDataSource + } else { + focusedTimelineMediaGalleryDataSourceFactory.createFor( + eventId = eventId, + mediaItem = inputs.toMediaItem(), + ) + } + } } private val galleryMode = when (inputs.mode) { @@ -81,6 +102,7 @@ class MediaViewerNode @AssistedInject constructor( mediaLoader = mediaLoader, localMediaFactory = localMediaFactory, systemClock = systemClock, + pagerKeysHandler = pagerKeysHandler, ) ) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt index cf363a70f1..4eeda0c352 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt @@ -28,13 +28,17 @@ data class MediaViewerState( ) sealed interface MediaViewerPageData { + val pagerKey: Long + data class Failure( val throwable: Throwable, + override val pagerKey: Long = 0, ) : MediaViewerPageData data class Loading( val direction: Timeline.PaginationDirection, val timestamp: Long, + override val pagerKey: Long, ) : MediaViewerPageData data class MediaViewerData( @@ -43,5 +47,14 @@ sealed interface MediaViewerPageData { val mediaSource: MediaSource, val thumbnailSource: MediaSource?, val downloadedMedia: State>, + override val pagerKey: Long, ) : MediaViewerPageData } + +fun MediaViewerPageData.toKey(): String { + return when (this) { + is MediaViewerPageData.Failure -> "Failure" + is MediaViewerPageData.Loading -> "Loading_${direction}" + is MediaViewerPageData.MediaViewerData -> eventId?.value ?: mediaSource.url + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt index 2c54751fed..95557c13d6 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt @@ -169,6 +169,7 @@ fun aMediaViewerPageDataLoading( return MediaViewerPageData.Loading( direction = direction, timestamp = timestamp, + pagerKey = 0L, ) } @@ -182,6 +183,7 @@ fun aMediaViewerPageData( mediaSource = mediaSource, thumbnailSource = null, downloadedMedia = mutableStateOf(downloadedMedia), + pagerKey = 0L, ) fun aMediaViewerState( diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index 51476193d0..3ba57cae33 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -114,6 +114,7 @@ fun MediaViewerView( modifier = Modifier, // Pre-load previous and next pages beyondViewportPageCount = 1, + key = { index -> state.listData[index].pagerKey }, ) { page -> when (val dataForPage = state.listData[page]) { is MediaViewerPageData.Failure -> { diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/PagerKeysHandler.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/PagerKeysHandler.kt new file mode 100644 index 0000000000..23ba312b0a --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/PagerKeysHandler.kt @@ -0,0 +1,86 @@ +/* + * 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.mediaviewer.impl.viewer + +import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem +import io.element.android.libraries.mediaviewer.impl.gallery.eventId +import javax.inject.Inject + +/** + * x and y are loading items. + * Capital letters are media items. + * First list emitted + * x F G H y + * indexes will be + * 0 1 2 3 4 + * (keyOffset = 0) + * New items added to the end of the list + * x F G H I J K y + * indexes will be + * 0 1 2 3 4 5 6 7 + * (keyOffset = 0) + * New items added to the beginning of the list + * x D E F G H I J K y + * indexes will be + * -2 -1 0 1 2 3 4 5 6 7 + * (keyOffset = -2) + * loader item vanishes + * D E F G H I J K + * indexes will be + * -1 0 1 2 3 4 5 6 + * (keyOffset = -1) + */ +class PagerKeysHandler @Inject constructor() { + private data class Data( + val mediaItems: List, + val keyOffset: Long, + ) + + // Will store the list of media items and the key offset of the first item in the list + private var cachedData: Data = Data(emptyList(), 0) + + fun accept(mediaItems: List) { + if (cachedData.mediaItems.isEmpty()) { + cachedData = Data(mediaItems, 0) + } else { + // Search a common item in both lists, i.e. an item with the same eventId + val itemInCacheIndex = cachedData.mediaItems.indexOfFirst { mediaItem -> + mediaItem is MediaItem.Event && mediaItems + .filterIsInstance() + .any { mediaItem.eventId() == it.eventId() } + } + cachedData = if (itemInCacheIndex == -1) { + // If the item is not found, start with a new cache + Data(mediaItems, 0) + } else { + val cachedItem = cachedData.mediaItems[itemInCacheIndex] + val eventId = (cachedItem as? MediaItem.Event)?.eventId() + if (eventId == null) { + // Should not happen, but in this case, start with a new cache + Data(mediaItems, 0) + } else { + // Search the index of the item in the new list + val itemIndex = mediaItems.indexOfFirst { mediaItem -> + mediaItem is MediaItem.Event && mediaItem.eventId() == eventId + } + if (itemIndex == -1) { + // If the item is not found, start with a new cache + Data(mediaItems, 0) + } else { + // Update the cache with the new list and the new offset + Data(mediaItems, cachedData.keyOffset + itemInCacheIndex - itemIndex.toLong()) + } + } + } + } + } + + fun getKey(mediaItem: MediaItem): Long { + return cachedData.mediaItems.indexOf(mediaItem) + cachedData.keyOffset + } +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaGalleryDataSourceTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaGalleryDataSourceTest.kt index 2f8cd634ec..27155ccc3e 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaGalleryDataSourceTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaGalleryDataSourceTest.kt @@ -260,6 +260,7 @@ class TimelineMediaGalleryDataSourceTest { ): TimelineMediaGalleryDataSource { return TimelineMediaGalleryDataSource( room = room, + mediaTimeline = LiveMediaTimeline(room), timelineMediaItemsFactory = TimelineMediaItemsFactory( dispatchers = testCoroutineDispatchers(), virtualItemFactory = VirtualItemFactory( diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt index 5348eb2aa3..e43afbf80e 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt @@ -122,10 +122,12 @@ class MediaViewerDataSourceTest { MediaViewerPageData.Loading( direction = Timeline.PaginationDirection.BACKWARDS, timestamp = A_FAKE_TIMESTAMP, + pagerKey = 0L, ), MediaViewerPageData.Loading( direction = Timeline.PaginationDirection.FORWARDS, timestamp = A_FAKE_TIMESTAMP, + pagerKey = 1L, ), ) } @@ -274,5 +276,6 @@ class MediaViewerDataSourceTest { mediaLoader = mediaLoader, localMediaFactory = localMediaFactory, systemClock = FakeSystemClock(), + pagerKeysHandler = PagerKeysHandler(), ) } diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt index 8a2d536a78..079788c1bc 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt @@ -792,6 +792,7 @@ class MediaViewerPresenterTest { mediaLoader = matrixMediaLoader, localMediaFactory = localMediaFactory, systemClock = FakeSystemClock(), + pagerKeysHandler = PagerKeysHandler(), ), room = room, localMediaActions = localMediaActions, diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/PagerKeysHandlerTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/PagerKeysHandlerTest.kt new file mode 100644 index 0000000000..056a62c86f --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/PagerKeysHandlerTest.kt @@ -0,0 +1,76 @@ +/* + * 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.mediaviewer.impl.viewer + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 +import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage +import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemLoadingIndicator +import org.junit.Test + +class PagerKeysHandlerTest { + private val image1 = aMediaItemImage( + eventId = AN_EVENT_ID, + ) + private val image2 = aMediaItemImage( + eventId = AN_EVENT_ID_2, + ) + private val aBackwardLoadingIndicator = aMediaItemLoadingIndicator( + direction = Timeline.PaginationDirection.BACKWARDS + ) + private val aForwardLoadingIndicator = aMediaItemLoadingIndicator( + direction = Timeline.PaginationDirection.FORWARDS + ) + + @Test + fun `when new items are inserted after existing items, keys are not shifted`() { + val sut = PagerKeysHandler() + sut.accept(listOf(aBackwardLoadingIndicator, image1, aForwardLoadingIndicator)) + assertThat(sut.getKey(aBackwardLoadingIndicator)).isEqualTo(0) + assertThat(sut.getKey(image1)).isEqualTo(1) + assertThat(sut.getKey(aForwardLoadingIndicator)).isEqualTo(2) + sut.accept(listOf(aBackwardLoadingIndicator, image1, image2, aForwardLoadingIndicator)) + assertThat(sut.getKey(aBackwardLoadingIndicator)).isEqualTo(0) + assertThat(sut.getKey(image1)).isEqualTo(1) + assertThat(sut.getKey(image2)).isEqualTo(2) + assertThat(sut.getKey(aForwardLoadingIndicator)).isEqualTo(3) + } + + @Test + fun `when new items are inserted before existing items, keys are not shifted`() { + val sut = PagerKeysHandler() + sut.accept(listOf(aBackwardLoadingIndicator, image1, aForwardLoadingIndicator)) + assertThat(sut.getKey(aBackwardLoadingIndicator)).isEqualTo(0) + assertThat(sut.getKey(image1)).isEqualTo(1) + assertThat(sut.getKey(aForwardLoadingIndicator)).isEqualTo(2) + sut.accept(listOf(aBackwardLoadingIndicator, image2, image1, aForwardLoadingIndicator)) + assertThat(sut.getKey(aBackwardLoadingIndicator)).isEqualTo(-1) + assertThat(sut.getKey(image2)).isEqualTo(0) + assertThat(sut.getKey(image1)).isEqualTo(1) + assertThat(sut.getKey(aForwardLoadingIndicator)).isEqualTo(2) + // Accepting the same list should not change the keys + sut.accept(listOf(aBackwardLoadingIndicator, image2, image1, aForwardLoadingIndicator)) + assertThat(sut.getKey(aBackwardLoadingIndicator)).isEqualTo(-1) + assertThat(sut.getKey(image2)).isEqualTo(0) + assertThat(sut.getKey(image1)).isEqualTo(1) + assertThat(sut.getKey(aForwardLoadingIndicator)).isEqualTo(2) + } + + @Test + fun `when loaders are removed, keys are not shifted`() { + val sut = PagerKeysHandler() + sut.accept(listOf(aBackwardLoadingIndicator, image1, aForwardLoadingIndicator)) + assertThat(sut.getKey(aBackwardLoadingIndicator)).isEqualTo(0) + assertThat(sut.getKey(image1)).isEqualTo(1) + assertThat(sut.getKey(aForwardLoadingIndicator)).isEqualTo(2) + sut.accept(listOf(image1)) + assertThat(sut.getKey(image1)).isEqualTo(1) + } +}