Skip to content

Commit 23e2caa

Browse files
authored
Merge pull request #4201 from element-hq/feature/bma/mediaSwipeEndOfRoom
Media Viewer: show snackbar when reaching end of timeline.
2 parents af5491b + 792c350 commit 23e2caa

File tree

2 files changed

+274
-7
lines changed

2 files changed

+274
-7
lines changed

libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,17 @@ package io.element.android.libraries.mediaviewer.impl.viewer
1010
import android.content.ActivityNotFoundException
1111
import androidx.compose.runtime.Composable
1212
import androidx.compose.runtime.DisposableEffect
13+
import androidx.compose.runtime.IntState
14+
import androidx.compose.runtime.LaunchedEffect
15+
import androidx.compose.runtime.State
16+
import androidx.compose.runtime.derivedStateOf
1317
import androidx.compose.runtime.getValue
1418
import androidx.compose.runtime.mutableIntStateOf
1519
import androidx.compose.runtime.mutableStateOf
1620
import androidx.compose.runtime.remember
1721
import androidx.compose.runtime.rememberCoroutineScope
1822
import androidx.compose.runtime.setValue
23+
import androidx.compose.runtime.snapshotFlow
1924
import dagger.assisted.Assisted
2025
import dagger.assisted.AssistedFactory
2126
import dagger.assisted.AssistedInject
@@ -31,10 +36,16 @@ import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
3136
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
3237
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
3338
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
39+
import io.element.android.libraries.mediaviewer.impl.R
3440
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
3541
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions
3642
import io.element.android.libraries.ui.strings.CommonStrings
43+
import kotlinx.collections.immutable.PersistentList
3744
import kotlinx.coroutines.CoroutineScope
45+
import kotlinx.coroutines.flow.distinctUntilChanged
46+
import kotlinx.coroutines.flow.filter
47+
import kotlinx.coroutines.flow.launchIn
48+
import kotlinx.coroutines.flow.onEach
3849
import kotlinx.coroutines.launch
3950
import io.element.android.libraries.androidutils.R as UtilsR
4051

@@ -60,10 +71,13 @@ class MediaViewerPresenter @AssistedInject constructor(
6071
@Composable
6172
override fun present(): MediaViewerState {
6273
val coroutineScope = rememberCoroutineScope()
63-
val data by dataSource.collectAsState()
64-
var currentIndex by remember { mutableIntStateOf(searchIndex(data, inputs.eventId)) }
74+
val data = dataSource.collectAsState()
75+
val currentIndex = remember { mutableIntStateOf(searchIndex(data.value, inputs.eventId)) }
6576
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
6677

78+
NoMoreItemsBackwardSnackBarDisplayer(currentIndex, data)
79+
NoMoreItemsForwardSnackBarDisplayer(currentIndex, data)
80+
6781
var mediaBottomSheetState by remember { mutableStateOf<MediaBottomSheetState>(MediaBottomSheetState.Hidden) }
6882

6983
DisposableEffect(Unit) {
@@ -125,7 +139,7 @@ class MediaViewerPresenter @AssistedInject constructor(
125139
mediaBottomSheetState = MediaBottomSheetState.Hidden
126140
}
127141
is MediaViewerEvents.OnNavigateTo -> {
128-
currentIndex = event.index
142+
currentIndex.intValue = event.index
129143
}
130144
is MediaViewerEvents.LoadMore -> coroutineScope.launch {
131145
dataSource.loadMore(event.direction)
@@ -134,15 +148,69 @@ class MediaViewerPresenter @AssistedInject constructor(
134148
}
135149

136150
return MediaViewerState(
137-
listData = data,
138-
currentIndex = currentIndex,
151+
listData = data.value,
152+
currentIndex = currentIndex.intValue,
139153
snackbarMessage = snackbarMessage,
140154
canShowInfo = inputs.canShowInfo,
141155
mediaBottomSheetState = mediaBottomSheetState,
142156
eventSink = ::handleEvents
143157
)
144158
}
145159

160+
@Composable
161+
private fun NoMoreItemsBackwardSnackBarDisplayer(
162+
currentIndex: IntState,
163+
data: State<PersistentList<MediaViewerPageData>>,
164+
) {
165+
val isRenderingLoadingBackward by remember {
166+
derivedStateOf {
167+
currentIndex.intValue == data.value.lastIndex && data.value.lastOrNull() is MediaViewerPageData.Loading
168+
}
169+
}
170+
if (isRenderingLoadingBackward) {
171+
LaunchedEffect(Unit) {
172+
// Observe the loading data vanishing
173+
snapshotFlow { data.value.lastOrNull() is MediaViewerPageData.Loading }
174+
.distinctUntilChanged()
175+
.filter { !it }
176+
.onEach { showNoMoreItemsSnackbar() }
177+
.launchIn(this)
178+
}
179+
}
180+
}
181+
182+
@Composable
183+
private fun NoMoreItemsForwardSnackBarDisplayer(
184+
currentIndex: IntState,
185+
data: State<PersistentList<MediaViewerPageData>>,
186+
) {
187+
val isRenderingLoadingForward by remember {
188+
derivedStateOf {
189+
currentIndex.intValue == 0 && data.value.firstOrNull() is MediaViewerPageData.Loading
190+
}
191+
}
192+
if (isRenderingLoadingForward) {
193+
LaunchedEffect(Unit) {
194+
// Observe the loading data vanishing
195+
snapshotFlow { data.value.firstOrNull() is MediaViewerPageData.Loading }
196+
.distinctUntilChanged()
197+
.filter { !it }
198+
.onEach { showNoMoreItemsSnackbar() }
199+
.launchIn(this)
200+
}
201+
}
202+
}
203+
204+
private fun showNoMoreItemsSnackbar() {
205+
val messageResId = when (inputs.mode) {
206+
MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
207+
MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> R.string.screen_media_details_no_more_media_to_show
208+
MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> R.string.screen_media_details_no_more_files_to_show
209+
}
210+
val message = SnackbarMessage(messageResId)
211+
snackbarDispatcher.post(message)
212+
}
213+
146214
private fun CoroutineScope.downloadMedia(
147215
data: MediaViewerPageData.MediaViewerData,
148216
) = launch {

libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt

Lines changed: 201 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,14 @@ import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
2828
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
2929
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
3030
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
31+
import io.element.android.libraries.mediaviewer.impl.R
3132
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
3233
import io.element.android.libraries.mediaviewer.impl.gallery.FakeMediaGalleryDataSource
3334
import io.element.android.libraries.mediaviewer.impl.gallery.GroupedMediaItems
3435
import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryDataSource
3536
import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryMode
3637
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage
38+
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemLoadingIndicator
3739
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions
3840
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
3941
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
@@ -54,6 +56,7 @@ private val TESTED_MEDIA_INFO = anApkMediaInfo(
5456
senderId = A_USER_ID,
5557
)
5658

59+
@Suppress("LargeClass")
5760
class MediaViewerPresenterTest {
5861
@get:Rule
5962
val warmUpRule = WarmUpRule()
@@ -62,6 +65,16 @@ class MediaViewerPresenterTest {
6265
private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri)
6366
private val aUrl = "aUrl"
6467

68+
private val anImage = aMediaItemImage(
69+
mediaSourceUrl = aUrl,
70+
)
71+
private val aBackwardLoadingIndicator = aMediaItemLoadingIndicator(
72+
direction = Timeline.PaginationDirection.BACKWARDS
73+
)
74+
private val aForwardLoadingIndicator = aMediaItemLoadingIndicator(
75+
direction = Timeline.PaginationDirection.FORWARDS
76+
)
77+
6578
@Test
6679
fun `present - initial state null Event`() = runTest {
6780
val presenter = createMediaViewerPresenter(
@@ -504,6 +517,187 @@ class MediaViewerPresenterTest {
504517
}
505518
}
506519

520+
@Test
521+
fun `present - snackbar displayed when there is no more items forward images and videos`() {
522+
`present - snackbar displayed when there is no more items forward`(
523+
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos,
524+
expectedSnackbarResId = R.string.screen_media_details_no_more_media_to_show,
525+
)
526+
}
527+
528+
@Test
529+
fun `present - snackbar displayed when there is no more items forward files and audio`() {
530+
`present - snackbar displayed when there is no more items forward`(
531+
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios,
532+
expectedSnackbarResId = R.string.screen_media_details_no_more_files_to_show,
533+
)
534+
}
535+
536+
private fun `present - snackbar displayed when there is no more items forward`(
537+
mode: MediaViewerEntryPoint.MediaViewerMode,
538+
expectedSnackbarResId: Int,
539+
) = runTest {
540+
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
541+
startLambda = { },
542+
)
543+
val presenter = createMediaViewerPresenter(
544+
mode = mode,
545+
mediaGalleryDataSource = mediaGalleryDataSource,
546+
)
547+
presenter.test {
548+
awaitFirstItem()
549+
mediaGalleryDataSource.emitGroupedMediaItems(
550+
AsyncData.Success(
551+
if (mode == MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) {
552+
GroupedMediaItems(
553+
imageAndVideoItems = persistentListOf(),
554+
fileItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator),
555+
)
556+
} else {
557+
GroupedMediaItems(
558+
imageAndVideoItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator),
559+
fileItems = persistentListOf(),
560+
)
561+
}
562+
)
563+
)
564+
val updatedState = awaitItem()
565+
// User navigate to the first item (forward loading indicator)
566+
updatedState.eventSink(
567+
MediaViewerEvents.OnNavigateTo(0)
568+
)
569+
// data source claims that there is no more items to load forward
570+
mediaGalleryDataSource.emitGroupedMediaItems(
571+
AsyncData.Success(
572+
if (mode == MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) {
573+
GroupedMediaItems(
574+
imageAndVideoItems = persistentListOf(),
575+
fileItems = persistentListOf(anImage, aBackwardLoadingIndicator),
576+
)
577+
} else {
578+
GroupedMediaItems(
579+
imageAndVideoItems = persistentListOf(anImage, aBackwardLoadingIndicator),
580+
fileItems = persistentListOf(),
581+
)
582+
}
583+
)
584+
)
585+
skipItems(1)
586+
val stateWithSnackbar = awaitItem()
587+
assertThat(stateWithSnackbar.snackbarMessage!!.messageResId).isEqualTo(expectedSnackbarResId)
588+
}
589+
}
590+
591+
@Test
592+
fun `present - snackbar displayed when there is no more items backward images and videos`() {
593+
`present - snackbar displayed when there is no more items backward`(
594+
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos,
595+
expectedSnackbarResId = R.string.screen_media_details_no_more_media_to_show,
596+
)
597+
}
598+
599+
@Test
600+
fun `present - snackbar displayed when there is no more items backward files and audio`() {
601+
`present - snackbar displayed when there is no more items backward`(
602+
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios,
603+
expectedSnackbarResId = R.string.screen_media_details_no_more_files_to_show,
604+
)
605+
}
606+
607+
private fun `present - snackbar displayed when there is no more items backward`(
608+
mode: MediaViewerEntryPoint.MediaViewerMode,
609+
expectedSnackbarResId: Int,
610+
) = runTest {
611+
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
612+
startLambda = { },
613+
)
614+
val presenter = createMediaViewerPresenter(
615+
mode = mode,
616+
mediaGalleryDataSource = mediaGalleryDataSource,
617+
)
618+
presenter.test {
619+
awaitFirstItem()
620+
mediaGalleryDataSource.emitGroupedMediaItems(
621+
AsyncData.Success(
622+
if (mode == MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) {
623+
GroupedMediaItems(
624+
imageAndVideoItems = persistentListOf(),
625+
fileItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator),
626+
)
627+
} else {
628+
GroupedMediaItems(
629+
imageAndVideoItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator),
630+
fileItems = persistentListOf(),
631+
)
632+
}
633+
)
634+
)
635+
val updatedState = awaitItem()
636+
// User navigate to the last item (backward loading indicator)
637+
updatedState.eventSink(
638+
MediaViewerEvents.OnNavigateTo(2)
639+
)
640+
skipItems(1)
641+
// data source claims that there is no more items to load backward
642+
mediaGalleryDataSource.emitGroupedMediaItems(
643+
AsyncData.Success(
644+
if (mode == MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) {
645+
GroupedMediaItems(
646+
imageAndVideoItems = persistentListOf(),
647+
fileItems = persistentListOf(aForwardLoadingIndicator, anImage),
648+
)
649+
} else {
650+
GroupedMediaItems(
651+
imageAndVideoItems = persistentListOf(aForwardLoadingIndicator, anImage),
652+
fileItems = persistentListOf(),
653+
)
654+
}
655+
)
656+
)
657+
skipItems(1)
658+
val stateWithSnackbar = awaitItem()
659+
assertThat(stateWithSnackbar.snackbarMessage!!.messageResId).isEqualTo(expectedSnackbarResId)
660+
}
661+
}
662+
663+
@Test
664+
fun `present - no snackbar displayed when there is no more items but not displaying a loading item`() = runTest {
665+
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
666+
startLambda = { },
667+
)
668+
val presenter = createMediaViewerPresenter(
669+
mediaGalleryDataSource = mediaGalleryDataSource,
670+
)
671+
presenter.test {
672+
awaitFirstItem()
673+
mediaGalleryDataSource.emitGroupedMediaItems(
674+
AsyncData.Success(
675+
GroupedMediaItems(
676+
imageAndVideoItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator),
677+
fileItems = persistentListOf(),
678+
)
679+
)
680+
)
681+
val updatedState = awaitItem()
682+
// User navigate to the media
683+
updatedState.eventSink(
684+
MediaViewerEvents.OnNavigateTo(1)
685+
)
686+
skipItems(1)
687+
// data source claims that there is no more items to load at all
688+
mediaGalleryDataSource.emitGroupedMediaItems(
689+
AsyncData.Success(
690+
GroupedMediaItems(
691+
imageAndVideoItems = persistentListOf(anImage),
692+
fileItems = persistentListOf(),
693+
)
694+
)
695+
)
696+
val finalState = awaitItem()
697+
assertThat(finalState.snackbarMessage).isNull()
698+
}
699+
}
700+
507701
@Test
508702
fun `present - load more`() = runTest {
509703
val loadMoreLambda = lambdaRecorder<Timeline.PaginationDirection, Unit> { }
@@ -565,6 +759,7 @@ class MediaViewerPresenterTest {
565759

566760
private fun TestScope.createMediaViewerPresenter(
567761
eventId: EventId? = null,
762+
mode: MediaViewerEntryPoint.MediaViewerMode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
568763
matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(),
569764
localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(),
570765
mediaGalleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource(
@@ -578,7 +773,7 @@ class MediaViewerPresenterTest {
578773
): MediaViewerPresenter {
579774
return MediaViewerPresenter(
580775
inputs = MediaViewerEntryPoint.Params(
581-
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
776+
mode = mode,
582777
eventId = eventId,
583778
mediaInfo = TESTED_MEDIA_INFO,
584779
mediaSource = aMediaSource(),
@@ -587,7 +782,11 @@ class MediaViewerPresenterTest {
587782
),
588783
navigator = mediaViewerNavigator,
589784
dataSource = MediaViewerDataSource(
590-
galleryMode = MediaGalleryMode.Images,
785+
galleryMode = when (mode) {
786+
MediaViewerEntryPoint.MediaViewerMode.SingleMedia -> MediaGalleryMode.Images
787+
MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> MediaGalleryMode.Images
788+
MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> MediaGalleryMode.Files
789+
},
591790
dispatcher = testCoroutineDispatchers().computation,
592791
galleryDataSource = mediaGalleryDataSource,
593792
mediaLoader = matrixMediaLoader,

0 commit comments

Comments
 (0)