Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Media Viewer: show snackbar when reaching end of timeline. #4201

Merged
merged 4 commits into from
Jan 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -134,15 +148,69 @@ class MediaViewerPresenter @AssistedInject constructor(
}

return MediaViewerState(
listData = data,
currentIndex = currentIndex,
listData = data.value,
currentIndex = currentIndex.intValue,
snackbarMessage = snackbarMessage,
canShowInfo = inputs.canShowInfo,
mediaBottomSheetState = mediaBottomSheetState,
eventSink = ::handleEvents
)
}

@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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -54,6 +56,7 @@ private val TESTED_MEDIA_INFO = anApkMediaInfo(
senderId = A_USER_ID,
)

@Suppress("LargeClass")
class MediaViewerPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
Expand All @@ -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(
Expand Down Expand Up @@ -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> { }
Expand Down Expand Up @@ -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(
Expand All @@ -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(),
Expand All @@ -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,
Expand Down
Loading