diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt index b039576e0a..b926291461 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt @@ -10,12 +10,17 @@ package io.element.android.libraries.mediaviewer.impl.viewer import android.content.ActivityNotFoundException import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.IntState +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -31,10 +36,16 @@ import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.impl.R 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.PersistentList import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import io.element.android.libraries.androidutils.R as UtilsR @@ -60,10 +71,13 @@ class MediaViewerPresenter @AssistedInject constructor( @Composable override fun present(): MediaViewerState { val coroutineScope = rememberCoroutineScope() - val data by dataSource.collectAsState() - var currentIndex by remember { mutableIntStateOf(searchIndex(data, inputs.eventId)) } + val data = dataSource.collectAsState() + val currentIndex = remember { mutableIntStateOf(searchIndex(data.value, inputs.eventId)) } val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() + NoMoreItemsBackwardSnackBarDisplayer(currentIndex, data) + NoMoreItemsForwardSnackBarDisplayer(currentIndex, data) + var mediaBottomSheetState by remember { mutableStateOf(MediaBottomSheetState.Hidden) } DisposableEffect(Unit) { @@ -125,7 +139,7 @@ class MediaViewerPresenter @AssistedInject constructor( mediaBottomSheetState = MediaBottomSheetState.Hidden } is MediaViewerEvents.OnNavigateTo -> { - currentIndex = event.index + currentIndex.intValue = event.index } is MediaViewerEvents.LoadMore -> coroutineScope.launch { dataSource.loadMore(event.direction) @@ -134,8 +148,8 @@ class MediaViewerPresenter @AssistedInject constructor( } return MediaViewerState( - listData = data, - currentIndex = currentIndex, + listData = data.value, + currentIndex = currentIndex.intValue, snackbarMessage = snackbarMessage, canShowInfo = inputs.canShowInfo, mediaBottomSheetState = mediaBottomSheetState, @@ -143,6 +157,60 @@ class MediaViewerPresenter @AssistedInject constructor( ) } + @Composable + private fun NoMoreItemsBackwardSnackBarDisplayer( + currentIndex: IntState, + data: State>, + ) { + val isRenderingLoadingBackward by remember { + derivedStateOf { + currentIndex.intValue == data.value.lastIndex && data.value.lastOrNull() is MediaViewerPageData.Loading + } + } + if (isRenderingLoadingBackward) { + LaunchedEffect(Unit) { + // Observe the loading data vanishing + snapshotFlow { data.value.lastOrNull() is MediaViewerPageData.Loading } + .distinctUntilChanged() + .filter { !it } + .onEach { showNoMoreItemsSnackbar() } + .launchIn(this) + } + } + } + + @Composable + private fun NoMoreItemsForwardSnackBarDisplayer( + currentIndex: IntState, + data: State>, + ) { + val isRenderingLoadingForward by remember { + derivedStateOf { + currentIndex.intValue == 0 && data.value.firstOrNull() is MediaViewerPageData.Loading + } + } + if (isRenderingLoadingForward) { + LaunchedEffect(Unit) { + // Observe the loading data vanishing + snapshotFlow { data.value.firstOrNull() is MediaViewerPageData.Loading } + .distinctUntilChanged() + .filter { !it } + .onEach { showNoMoreItemsSnackbar() } + .launchIn(this) + } + } + } + + private fun showNoMoreItemsSnackbar() { + val messageResId = when (inputs.mode) { + MediaViewerEntryPoint.MediaViewerMode.SingleMedia, + MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> R.string.screen_media_details_no_more_media_to_show + MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> R.string.screen_media_details_no_more_files_to_show + } + val message = SnackbarMessage(messageResId) + snackbarDispatcher.post(message) + } + private fun CoroutineScope.downloadMedia( data: MediaViewerPageData.MediaViewerData, ) = launch { 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 47e48ca9e1..8a2d536a78 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 @@ -28,12 +28,14 @@ import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.libraries.mediaviewer.api.anApkMediaInfo +import io.element.android.libraries.mediaviewer.impl.R import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState import io.element.android.libraries.mediaviewer.impl.gallery.FakeMediaGalleryDataSource import io.element.android.libraries.mediaviewer.impl.gallery.GroupedMediaItems import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryDataSource import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryMode import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage +import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemLoadingIndicator import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory import io.element.android.services.toolbox.test.systemclock.FakeSystemClock @@ -54,6 +56,7 @@ private val TESTED_MEDIA_INFO = anApkMediaInfo( senderId = A_USER_ID, ) +@Suppress("LargeClass") class MediaViewerPresenterTest { @get:Rule val warmUpRule = WarmUpRule() @@ -62,6 +65,16 @@ class MediaViewerPresenterTest { private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri) private val aUrl = "aUrl" + private val anImage = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + private val aBackwardLoadingIndicator = aMediaItemLoadingIndicator( + direction = Timeline.PaginationDirection.BACKWARDS + ) + private val aForwardLoadingIndicator = aMediaItemLoadingIndicator( + direction = Timeline.PaginationDirection.FORWARDS + ) + @Test fun `present - initial state null Event`() = runTest { val presenter = createMediaViewerPresenter( @@ -504,6 +517,187 @@ class MediaViewerPresenterTest { } } + @Test + fun `present - snackbar displayed when there is no more items forward images and videos`() { + `present - snackbar displayed when there is no more items forward`( + mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos, + expectedSnackbarResId = R.string.screen_media_details_no_more_media_to_show, + ) + } + + @Test + fun `present - snackbar displayed when there is no more items forward files and audio`() { + `present - snackbar displayed when there is no more items forward`( + mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios, + expectedSnackbarResId = R.string.screen_media_details_no_more_files_to_show, + ) + } + + private fun `present - snackbar displayed when there is no more items forward`( + mode: MediaViewerEntryPoint.MediaViewerMode, + expectedSnackbarResId: Int, + ) = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + val presenter = createMediaViewerPresenter( + mode = mode, + mediaGalleryDataSource = mediaGalleryDataSource, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + if (mode == MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) { + GroupedMediaItems( + imageAndVideoItems = persistentListOf(), + fileItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator), + ) + } else { + GroupedMediaItems( + imageAndVideoItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator), + fileItems = persistentListOf(), + ) + } + ) + ) + val updatedState = awaitItem() + // User navigate to the first item (forward loading indicator) + updatedState.eventSink( + MediaViewerEvents.OnNavigateTo(0) + ) + // data source claims that there is no more items to load forward + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + if (mode == MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) { + GroupedMediaItems( + imageAndVideoItems = persistentListOf(), + fileItems = persistentListOf(anImage, aBackwardLoadingIndicator), + ) + } else { + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage, aBackwardLoadingIndicator), + fileItems = persistentListOf(), + ) + } + ) + ) + skipItems(1) + val stateWithSnackbar = awaitItem() + assertThat(stateWithSnackbar.snackbarMessage!!.messageResId).isEqualTo(expectedSnackbarResId) + } + } + + @Test + fun `present - snackbar displayed when there is no more items backward images and videos`() { + `present - snackbar displayed when there is no more items backward`( + mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos, + expectedSnackbarResId = R.string.screen_media_details_no_more_media_to_show, + ) + } + + @Test + fun `present - snackbar displayed when there is no more items backward files and audio`() { + `present - snackbar displayed when there is no more items backward`( + mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios, + expectedSnackbarResId = R.string.screen_media_details_no_more_files_to_show, + ) + } + + private fun `present - snackbar displayed when there is no more items backward`( + mode: MediaViewerEntryPoint.MediaViewerMode, + expectedSnackbarResId: Int, + ) = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + val presenter = createMediaViewerPresenter( + mode = mode, + mediaGalleryDataSource = mediaGalleryDataSource, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + if (mode == MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) { + GroupedMediaItems( + imageAndVideoItems = persistentListOf(), + fileItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator), + ) + } else { + GroupedMediaItems( + imageAndVideoItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator), + fileItems = persistentListOf(), + ) + } + ) + ) + val updatedState = awaitItem() + // User navigate to the last item (backward loading indicator) + updatedState.eventSink( + MediaViewerEvents.OnNavigateTo(2) + ) + skipItems(1) + // data source claims that there is no more items to load backward + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + if (mode == MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) { + GroupedMediaItems( + imageAndVideoItems = persistentListOf(), + fileItems = persistentListOf(aForwardLoadingIndicator, anImage), + ) + } else { + GroupedMediaItems( + imageAndVideoItems = persistentListOf(aForwardLoadingIndicator, anImage), + fileItems = persistentListOf(), + ) + } + ) + ) + skipItems(1) + val stateWithSnackbar = awaitItem() + assertThat(stateWithSnackbar.snackbarMessage!!.messageResId).isEqualTo(expectedSnackbarResId) + } + } + + @Test + fun `present - no snackbar displayed when there is no more items but not displaying a loading item`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + val presenter = createMediaViewerPresenter( + mediaGalleryDataSource = mediaGalleryDataSource, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + // User navigate to the media + updatedState.eventSink( + MediaViewerEvents.OnNavigateTo(1) + ) + skipItems(1) + // data source claims that there is no more items to load at all + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val finalState = awaitItem() + assertThat(finalState.snackbarMessage).isNull() + } + } + @Test fun `present - load more`() = runTest { val loadMoreLambda = lambdaRecorder { } @@ -565,6 +759,7 @@ class MediaViewerPresenterTest { private fun TestScope.createMediaViewerPresenter( eventId: EventId? = null, + mode: MediaViewerEntryPoint.MediaViewerMode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia, matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(), localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(), mediaGalleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource( @@ -578,7 +773,7 @@ class MediaViewerPresenterTest { ): MediaViewerPresenter { return MediaViewerPresenter( inputs = MediaViewerEntryPoint.Params( - mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia, + mode = mode, eventId = eventId, mediaInfo = TESTED_MEDIA_INFO, mediaSource = aMediaSource(), @@ -587,7 +782,11 @@ class MediaViewerPresenterTest { ), navigator = mediaViewerNavigator, dataSource = MediaViewerDataSource( - galleryMode = MediaGalleryMode.Images, + galleryMode = when (mode) { + MediaViewerEntryPoint.MediaViewerMode.SingleMedia -> MediaGalleryMode.Images + MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> MediaGalleryMode.Images + MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> MediaGalleryMode.Files + }, dispatcher = testCoroutineDispatchers().computation, galleryDataSource = mediaGalleryDataSource, mediaLoader = matrixMediaLoader,