Merge pull request #4201 from element-hq/feature/bma/mediaSwipeEndOfRoom

Media Viewer: show snackbar when reaching end of timeline.
This commit is contained in:
Benoit Marty 2025-01-27 16:21:47 +01:00 committed by GitHub
commit 5696ba0d24
2 changed files with 274 additions and 7 deletions

View file

@ -10,12 +10,17 @@ package io.element.android.libraries.mediaviewer.impl.viewer
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect 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.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject 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.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.api.local.LocalMedia 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.details.MediaBottomSheetState
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.PersistentList
import kotlinx.coroutines.CoroutineScope 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 kotlinx.coroutines.launch
import io.element.android.libraries.androidutils.R as UtilsR import io.element.android.libraries.androidutils.R as UtilsR
@ -60,10 +71,13 @@ class MediaViewerPresenter @AssistedInject constructor(
@Composable @Composable
override fun present(): MediaViewerState { override fun present(): MediaViewerState {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val data by dataSource.collectAsState() val data = dataSource.collectAsState()
var currentIndex by remember { mutableIntStateOf(searchIndex(data, inputs.eventId)) } val currentIndex = remember { mutableIntStateOf(searchIndex(data.value, inputs.eventId)) }
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
NoMoreItemsBackwardSnackBarDisplayer(currentIndex, data)
NoMoreItemsForwardSnackBarDisplayer(currentIndex, data)
var mediaBottomSheetState by remember { mutableStateOf<MediaBottomSheetState>(MediaBottomSheetState.Hidden) } var mediaBottomSheetState by remember { mutableStateOf<MediaBottomSheetState>(MediaBottomSheetState.Hidden) }
DisposableEffect(Unit) { DisposableEffect(Unit) {
@ -125,7 +139,7 @@ class MediaViewerPresenter @AssistedInject constructor(
mediaBottomSheetState = MediaBottomSheetState.Hidden mediaBottomSheetState = MediaBottomSheetState.Hidden
} }
is MediaViewerEvents.OnNavigateTo -> { is MediaViewerEvents.OnNavigateTo -> {
currentIndex = event.index currentIndex.intValue = event.index
} }
is MediaViewerEvents.LoadMore -> coroutineScope.launch { is MediaViewerEvents.LoadMore -> coroutineScope.launch {
dataSource.loadMore(event.direction) dataSource.loadMore(event.direction)
@ -134,8 +148,8 @@ class MediaViewerPresenter @AssistedInject constructor(
} }
return MediaViewerState( return MediaViewerState(
listData = data, listData = data.value,
currentIndex = currentIndex, currentIndex = currentIndex.intValue,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
canShowInfo = inputs.canShowInfo, canShowInfo = inputs.canShowInfo,
mediaBottomSheetState = mediaBottomSheetState, mediaBottomSheetState = mediaBottomSheetState,
@ -143,6 +157,60 @@ class MediaViewerPresenter @AssistedInject constructor(
) )
} }
@Composable
private fun NoMoreItemsBackwardSnackBarDisplayer(
currentIndex: IntState,
data: State<PersistentList<MediaViewerPageData>>,
) {
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<PersistentList<MediaViewerPageData>>,
) {
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( private fun CoroutineScope.downloadMedia(
data: MediaViewerPageData.MediaViewerData, data: MediaViewerPageData.MediaViewerData,
) = launch { ) = launch {

View file

@ -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.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo 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.details.MediaBottomSheetState
import io.element.android.libraries.mediaviewer.impl.gallery.FakeMediaGalleryDataSource 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.GroupedMediaItems
import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryDataSource 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.MediaGalleryMode
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage 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.FakeLocalMediaActions
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
@ -54,6 +56,7 @@ private val TESTED_MEDIA_INFO = anApkMediaInfo(
senderId = A_USER_ID, senderId = A_USER_ID,
) )
@Suppress("LargeClass")
class MediaViewerPresenterTest { class MediaViewerPresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@ -62,6 +65,16 @@ class MediaViewerPresenterTest {
private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri) private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri)
private val aUrl = "aUrl" 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 @Test
fun `present - initial state null Event`() = runTest { fun `present - initial state null Event`() = runTest {
val presenter = createMediaViewerPresenter( 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 @Test
fun `present - load more`() = runTest { fun `present - load more`() = runTest {
val loadMoreLambda = lambdaRecorder<Timeline.PaginationDirection, Unit> { } val loadMoreLambda = lambdaRecorder<Timeline.PaginationDirection, Unit> { }
@ -565,6 +759,7 @@ class MediaViewerPresenterTest {
private fun TestScope.createMediaViewerPresenter( private fun TestScope.createMediaViewerPresenter(
eventId: EventId? = null, eventId: EventId? = null,
mode: MediaViewerEntryPoint.MediaViewerMode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(), matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(),
localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(), localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(),
mediaGalleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource( mediaGalleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource(
@ -578,7 +773,7 @@ class MediaViewerPresenterTest {
): MediaViewerPresenter { ): MediaViewerPresenter {
return MediaViewerPresenter( return MediaViewerPresenter(
inputs = MediaViewerEntryPoint.Params( inputs = MediaViewerEntryPoint.Params(
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia, mode = mode,
eventId = eventId, eventId = eventId,
mediaInfo = TESTED_MEDIA_INFO, mediaInfo = TESTED_MEDIA_INFO,
mediaSource = aMediaSource(), mediaSource = aMediaSource(),
@ -587,7 +782,11 @@ class MediaViewerPresenterTest {
), ),
navigator = mediaViewerNavigator, navigator = mediaViewerNavigator,
dataSource = MediaViewerDataSource( 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, dispatcher = testCoroutineDispatchers().computation,
galleryDataSource = mediaGalleryDataSource, galleryDataSource = mediaGalleryDataSource,
mediaLoader = matrixMediaLoader, mediaLoader = matrixMediaLoader,