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 23e2caa1ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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 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>(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<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(
data: MediaViewerPageData.MediaViewerData,
) = 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.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<Timeline.PaginationDirection, Unit> { }
@ -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,