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 new file mode 100644 index 0000000000..b83fda1c65 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryDataSource.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.gallery + +import io.element.android.libraries.architecture.AsyncData +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.room.MatrixRoom +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject + +@SingleIn(RoomScope::class) +class MediaGalleryDataSource @Inject constructor( + private val room: MatrixRoom, + private val timelineMediaItemsFactory: TimelineMediaItemsFactory, + private val mediaItemsPostProcessor: MediaItemsPostProcessor, +) { + private var timeline: Timeline? = null + + private val groupedMediaItemsFlow = MutableSharedFlow>(replay = 1) + + fun groupedMediaItemsFlow(): Flow> = groupedMediaItemsFlow + + private val isStarted = AtomicBoolean(false) + + @OptIn(ExperimentalCoroutinesApi::class) + fun start() { + if (!isStarted.compareAndSet(false, true)) { + return + } + flow { + groupedMediaItemsFlow.emit(AsyncData.Loading()) + room.mediaTimeline().fold( + { + timeline = it + emit(it) + }, + { groupedMediaItemsFlow.emit(AsyncData.Failure(it)) }, + ) + }.flatMapLatest { timeline -> + timeline.timelineItems.onEach { + timelineMediaItemsFactory.replaceWith( + timelineItems = it, + ) + } + }.flatMapLatest { + timelineMediaItemsFactory.timelineItems + }.map { timelineItems -> + mediaItemsPostProcessor.process(mediaItems = timelineItems) + }.onEach { groupedMediaItems -> + groupedMediaItemsFlow.emit(AsyncData.Success(groupedMediaItems)) + } + .onCompletion { + timeline?.close() + } + .launchIn(room.roomCoroutineScope) + } + + suspend fun loadMore(direction: Timeline.PaginationDirection) { + timeline?.paginate(direction) + } + + suspend fun deleteItem(eventId: EventId) { + timeline?.redactEvent( + eventOrTransactionId = eventId.toEventOrTransactionId(), + reason = null, + ) + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt index f9d1e9c547..476e86c5c5 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt @@ -9,15 +9,12 @@ package io.element.android.libraries.mediaviewer.impl.gallery import android.content.ActivityNotFoundException import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -33,30 +30,21 @@ import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn -import io.element.android.libraries.matrix.api.timeline.Timeline -import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions import io.element.android.libraries.ui.strings.CommonStrings -import kotlinx.collections.immutable.ImmutableList -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch class MediaGalleryPresenter @AssistedInject constructor( @Assisted private val navigator: MediaGalleryNavigator, private val room: MatrixRoom, - private val timelineMediaItemsFactory: TimelineMediaItemsFactory, + private val mediaGalleryDataSource: MediaGalleryDataSource, private val localMediaFactory: LocalMediaFactory, private val mediaLoader: MatrixMediaLoader, private val localMediaActions: LocalMediaActions, private val snackbarDispatcher: SnackbarDispatcher, - private val mediaItemsPostProcessor: MediaItemsPostProcessor, ) : Presenter { @AssistedFactory interface Factory { @@ -74,56 +62,36 @@ class MediaGalleryPresenter @AssistedInject constructor( var mediaBottomSheetState by remember { mutableStateOf(MediaBottomSheetState.Hidden) } - var mediaItems by remember { - mutableStateOf>>(AsyncData.Uninitialized) - } val groupedMediaItems by remember { - derivedStateOf { - mediaItemsPostProcessor.process( - mediaItems = mediaItems, - ) - } + mediaGalleryDataSource.groupedMediaItemsFlow() } + .collectAsState(AsyncData.Uninitialized) + + LaunchedEffect(Unit) { + mediaGalleryDataSource.start() + } + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() localMediaActions.Configure() - var timeline by remember { mutableStateOf>(AsyncData.Uninitialized) } - LaunchedEffect(Unit) { - room.mediaTimeline() - .fold( - { timeline = AsyncData.Success(it) }, - { timeline = AsyncData.Failure(it) }, - ) - } - DisposableEffect(Unit) { - onDispose { - timeline.dataOrNull()?.close() - } - } - - MediaListEffect( - timeline = timeline, - onItemsChange = { newItems -> - mediaItems = newItems - } - ) - fun handleEvents(event: MediaGalleryEvents) { when (event) { is MediaGalleryEvents.ChangeMode -> { mode = event.mode } is MediaGalleryEvents.LoadMore -> coroutineScope.launch { - timeline.dataOrNull()?.paginate(event.direction) + mediaGalleryDataSource.loadMore(event.direction) + } + is MediaGalleryEvents.Delete -> coroutineScope.launch { + mediaGalleryDataSource.deleteItem(event.eventId) } - is MediaGalleryEvents.Delete -> coroutineScope.delete(timeline, event.eventId) is MediaGalleryEvents.SaveOnDisk -> coroutineScope.launch { - mediaItems.dataOrNull().find(event.eventId)?.let { + groupedMediaItems.dataOrNull().find(event.eventId)?.let { saveOnDisk(it) } } is MediaGalleryEvents.Share -> coroutineScope.launch { - mediaItems.dataOrNull().find(event.eventId)?.let { + groupedMediaItems.dataOrNull().find(event.eventId)?.let { share(it) } } @@ -172,49 +140,6 @@ class MediaGalleryPresenter @AssistedInject constructor( ) } - @Composable - private fun MediaListEffect( - timeline: AsyncData, - onItemsChange: (AsyncData>) -> Unit, - ) { - val updatedOnItemsChange by rememberUpdatedState(onItemsChange) - - LaunchedEffect(timeline) { - when (timeline) { - AsyncData.Uninitialized -> flowOf(AsyncData.Uninitialized) - is AsyncData.Failure -> flowOf(AsyncData.Failure(timeline.error)) - is AsyncData.Loading -> flowOf(AsyncData.Loading()) - is AsyncData.Success -> { - timeline.data.timelineItems - .onEach { items -> - timelineMediaItemsFactory.replaceWith( - timelineItems = items, - ) - } - .launchIn(this) - - timelineMediaItemsFactory.timelineItems.map { timelineItems -> - AsyncData.Success(timelineItems) - } - } - } - .onEach { items -> - updatedOnItemsChange(items) - } - .launchIn(this) - } - } - - private fun CoroutineScope.delete( - timeline: AsyncData, - eventId: EventId, - ) = launch { - timeline.dataOrNull()?.redactEvent( - eventOrTransactionId = eventId.toEventOrTransactionId(), - reason = null, - ) - } - private suspend fun downloadMedia(mediaItem: MediaItem.Event): Result { return mediaLoader.downloadMediaFile( source = mediaItem.mediaSource(), @@ -264,10 +189,10 @@ class MediaGalleryPresenter @AssistedInject constructor( } } -private fun List?.find(eventId: EventId?): MediaItem.Event? { +private fun GroupedMediaItems?.find(eventId: EventId?): MediaItem.Event? { if (this == null || eventId == null) { return null } - return filterIsInstance() + return (imageAndVideoItems + fileItems).filterIsInstance() .firstOrNull { it.eventId() == eventId } } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt index e5de5bbd58..3fb8d81b1f 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt @@ -7,32 +7,19 @@ package io.element.android.libraries.mediaviewer.impl.gallery -import io.element.android.libraries.architecture.AsyncData -import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import javax.inject.Inject class MediaItemsPostProcessor @Inject constructor() { fun process( - mediaItems: AsyncData>, - ): AsyncData { - return when (mediaItems) { - is AsyncData.Uninitialized -> AsyncData.Uninitialized - is AsyncData.Loading -> AsyncData.Loading() - is AsyncData.Failure -> AsyncData.Failure(mediaItems.error) - is AsyncData.Success -> AsyncData.Success( - mediaItems.data.process() - ) - } - } - - private fun List.process(): GroupedMediaItems { + mediaItems: List, + ): GroupedMediaItems { val imageAndVideoItems = mutableListOf() val fileItems = mutableListOf() val imageAndVideoItemsSubList = mutableListOf() val fileItemsSublist = mutableListOf() - forEach { item -> + mediaItems.forEach { item -> when (item) { is MediaItem.DateSeparator -> { if (imageAndVideoItemsSubList.isNotEmpty()) {