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 navigation with swipe gesture #4161

Merged
merged 38 commits into from
Jan 23, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
35cc5df
Create MediaGalleryDataSource and extract logic from MediaGalleryPres…
bmarty Jan 15, 2025
cbf7bf0
Let MediaGalleryDataSource be an interface
bmarty Jan 16, 2025
a06607f
Remove RetryLoading (use LoadMedia)
bmarty Jan 17, 2025
32db42a
Suppress Detekt false positive (?)
bmarty Jan 17, 2025
7df65b0
Add support for files navigation (when coming from the gallery)
bmarty Jan 17, 2025
ca5c06f
Open in SingleMedia mode when coming from the timeline
bmarty Jan 17, 2025
8d6550b
If not displayed, make sure to pause the audio / video
bmarty Jan 17, 2025
98a786b
media viewer : create MediaViewerDataSource
ganfra Jan 20, 2025
d26414f
Provide duration
bmarty Jan 17, 2025
bad4566
Remove unused import
bmarty Jan 20, 2025
3248041
media viewer : use collectAsState in the DataSource
ganfra Jan 21, 2025
c0542c8
Fix and write tests
bmarty Jan 21, 2025
d6ebec8
Improve loading state and add preview.
bmarty Jan 21, 2025
6c904a4
Add exception for Konsist
bmarty Jan 21, 2025
aa68a35
sync strings
bmarty Jan 21, 2025
a4cf1d7
Restore caption rendering
bmarty Jan 22, 2025
f286f6d
Small cleanup
bmarty Jan 22, 2025
428b81c
Add test on SingleMediaGalleryDataSource
bmarty Jan 22, 2025
18c3b5b
Add test on MediaViewerDataSource
bmarty Jan 22, 2025
621558a
MediaViewer: add error case in the UI.
bmarty Jan 22, 2025
e496bc3
Introduce MediaViewerFlickToDismiss and extract to its own file
bmarty Jan 22, 2025
dfda13a
Restore overlay when user cancel the dragging
bmarty Jan 22, 2025
bac69c4
Add timestamp to trigger back pagination.
bmarty Jan 22, 2025
b72f4bb
Fix tests.
bmarty Jan 22, 2025
74be5a5
Update screenshots
ElementBot Jan 22, 2025
1580c73
Ensure gallery is paginating to get new items.
bmarty Jan 23, 2025
77887f9
Fix formatting.
bmarty Jan 23, 2025
dff9005
Remove useless parameter
bmarty Jan 23, 2025
54182b0
Simplify with code `coerceAtLeast(0)``
bmarty Jan 23, 2025
2fde015
Simplify
bmarty Jan 23, 2025
afd8161
Add documentation on buildMediaViewerPageList.
bmarty Jan 23, 2025
a5515b0
Add name to argument for clarity.
bmarty Jan 23, 2025
7dd797b
Use Black for code clarity.
bmarty Jan 23, 2025
beb835c
Fix color for media viewer according to Figma.
bmarty Jan 23, 2025
05660fb
Cleanup
bmarty Jan 23, 2025
4d2edfa
Improve code clarity
bmarty Jan 23, 2025
ba0502c
Update screenshots
ElementBot Jan 23, 2025
da22758
Fix pagination restart issue and cover by unit test.
bmarty Jan 23, 2025
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
@@ -0,0 +1,86 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

package io.element.android.libraries.mediaviewer.impl.gallery

import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject

@SingleIn(RoomScope::class)
class MediaGalleryDataSource @Inject constructor(
private val room: MatrixRoom,
private val timelineMediaItemsFactory: TimelineMediaItemsFactory,
private val mediaItemsPostProcessor: MediaItemsPostProcessor,
) {
private var timeline: Timeline? = null

private val groupedMediaItemsFlow = MutableSharedFlow<AsyncData<GroupedMediaItems>>(replay = 1)

fun groupedMediaItemsFlow(): Flow<AsyncData<GroupedMediaItems>> = groupedMediaItemsFlow

private val isStarted = AtomicBoolean(false)

@OptIn(ExperimentalCoroutinesApi::class)
fun start() {
if (!isStarted.compareAndSet(false, true)) {
return
}
flow {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have a way to stop this flow, i.e. when the user leaves the gallery screen? Or is it ok if the gallery keeps loading in bg while the user is checking the room?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no way so far, but the flow will complete when the room will be destroyed.
The idea is to share the flow between the gallery and the media viewer with swipe. This will have to be reworked a bit when we will open the viewer from the timeline.

groupedMediaItemsFlow.emit(AsyncData.Loading())
room.mediaTimeline().fold(
{
timeline = it
emit(it)
},
{ groupedMediaItemsFlow.emit(AsyncData.Failure(it)) },
)
}.flatMapLatest { timeline ->
timeline.timelineItems.onEach {
timelineMediaItemsFactory.replaceWith(
timelineItems = it,
)
}
}.flatMapLatest {
timelineMediaItemsFactory.timelineItems
}.map { timelineItems ->
mediaItemsPostProcessor.process(mediaItems = timelineItems)
}.onEach { groupedMediaItems ->
groupedMediaItemsFlow.emit(AsyncData.Success(groupedMediaItems))
}
.onCompletion {
timeline?.close()
}
.launchIn(room.roomCoroutineScope)
}

suspend fun loadMore(direction: Timeline.PaginationDirection) {
timeline?.paginate(direction)
}

suspend fun deleteItem(eventId: EventId) {
timeline?.redactEvent(
eventOrTransactionId = eventId.toEventOrTransactionId(),
reason = null,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,12 @@ package io.element.android.libraries.mediaviewer.impl.gallery

import android.content.ActivityNotFoundException
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
Expand All @@ -33,30 +30,21 @@ import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
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.ImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch

class MediaGalleryPresenter @AssistedInject constructor(
@Assisted private val navigator: MediaGalleryNavigator,
private val room: MatrixRoom,
private val timelineMediaItemsFactory: TimelineMediaItemsFactory,
private val mediaGalleryDataSource: MediaGalleryDataSource,
private val localMediaFactory: LocalMediaFactory,
private val mediaLoader: MatrixMediaLoader,
private val localMediaActions: LocalMediaActions,
private val snackbarDispatcher: SnackbarDispatcher,
private val mediaItemsPostProcessor: MediaItemsPostProcessor,
) : Presenter<MediaGalleryState> {
@AssistedFactory
interface Factory {
Expand All @@ -74,56 +62,36 @@ class MediaGalleryPresenter @AssistedInject constructor(

var mediaBottomSheetState by remember { mutableStateOf<MediaBottomSheetState>(MediaBottomSheetState.Hidden) }

var mediaItems by remember {
mutableStateOf<AsyncData<ImmutableList<MediaItem>>>(AsyncData.Uninitialized)
}
val groupedMediaItems by remember {
derivedStateOf {
mediaItemsPostProcessor.process(
mediaItems = mediaItems,
)
}
mediaGalleryDataSource.groupedMediaItemsFlow()
}
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
localMediaActions.Configure()
.collectAsState(AsyncData.Uninitialized)

var timeline by remember { mutableStateOf<AsyncData<Timeline>>(AsyncData.Uninitialized) }
LaunchedEffect(Unit) {
room.mediaTimeline()
.fold(
{ timeline = AsyncData.Success(it) },
{ timeline = AsyncData.Failure(it) },
)
}
DisposableEffect(Unit) {
onDispose {
timeline.dataOrNull()?.close()
}
mediaGalleryDataSource.start()
}

MediaListEffect(
timeline = timeline,
onItemsChange = { newItems ->
mediaItems = newItems
}
)
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
localMediaActions.Configure()

fun handleEvents(event: MediaGalleryEvents) {
when (event) {
is MediaGalleryEvents.ChangeMode -> {
mode = event.mode
}
is MediaGalleryEvents.LoadMore -> coroutineScope.launch {
timeline.dataOrNull()?.paginate(event.direction)
mediaGalleryDataSource.loadMore(event.direction)
}
is MediaGalleryEvents.Delete -> coroutineScope.launch {
mediaGalleryDataSource.deleteItem(event.eventId)
}
is MediaGalleryEvents.Delete -> coroutineScope.delete(timeline, event.eventId)
is MediaGalleryEvents.SaveOnDisk -> coroutineScope.launch {
mediaItems.dataOrNull().find(event.eventId)?.let {
groupedMediaItems.dataOrNull().find(event.eventId)?.let {
saveOnDisk(it)
}
}
is MediaGalleryEvents.Share -> coroutineScope.launch {
mediaItems.dataOrNull().find(event.eventId)?.let {
groupedMediaItems.dataOrNull().find(event.eventId)?.let {
share(it)
}
}
Expand Down Expand Up @@ -172,49 +140,6 @@ class MediaGalleryPresenter @AssistedInject constructor(
)
}

@Composable
private fun MediaListEffect(
timeline: AsyncData<Timeline>,
onItemsChange: (AsyncData<ImmutableList<MediaItem>>) -> Unit,
) {
val updatedOnItemsChange by rememberUpdatedState(onItemsChange)

LaunchedEffect(timeline) {
when (timeline) {
AsyncData.Uninitialized -> flowOf(AsyncData.Uninitialized)
is AsyncData.Failure -> flowOf(AsyncData.Failure(timeline.error))
is AsyncData.Loading -> flowOf(AsyncData.Loading())
is AsyncData.Success -> {
timeline.data.timelineItems
.onEach { items ->
timelineMediaItemsFactory.replaceWith(
timelineItems = items,
)
}
.launchIn(this)

timelineMediaItemsFactory.timelineItems.map { timelineItems ->
AsyncData.Success(timelineItems)
}
}
}
.onEach { items ->
updatedOnItemsChange(items)
}
.launchIn(this)
}
}

private fun CoroutineScope.delete(
timeline: AsyncData<Timeline>,
eventId: EventId,
) = launch {
timeline.dataOrNull()?.redactEvent(
eventOrTransactionId = eventId.toEventOrTransactionId(),
reason = null,
)
}

private suspend fun downloadMedia(mediaItem: MediaItem.Event): Result<LocalMedia> {
return mediaLoader.downloadMediaFile(
source = mediaItem.mediaSource(),
Expand Down Expand Up @@ -264,10 +189,10 @@ class MediaGalleryPresenter @AssistedInject constructor(
}
}

private fun List<MediaItem>?.find(eventId: EventId?): MediaItem.Event? {
private fun GroupedMediaItems?.find(eventId: EventId?): MediaItem.Event? {
if (this == null || eventId == null) {
return null
}
return filterIsInstance<MediaItem.Event>()
return (imageAndVideoItems + fileItems).filterIsInstance<MediaItem.Event>()
.firstOrNull { it.eventId() == eventId }
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,19 @@

package io.element.android.libraries.mediaviewer.impl.gallery

import io.element.android.libraries.architecture.AsyncData
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import javax.inject.Inject

class MediaItemsPostProcessor @Inject constructor() {
fun process(
mediaItems: AsyncData<ImmutableList<MediaItem>>,
): AsyncData<GroupedMediaItems> {
return when (mediaItems) {
is AsyncData.Uninitialized -> AsyncData.Uninitialized
is AsyncData.Loading -> AsyncData.Loading()
is AsyncData.Failure -> AsyncData.Failure(mediaItems.error)
is AsyncData.Success -> AsyncData.Success(
mediaItems.data.process()
)
}
}

private fun List<MediaItem>.process(): GroupedMediaItems {
mediaItems: List<MediaItem>,
): GroupedMediaItems {
val imageAndVideoItems = mutableListOf<MediaItem>()
val fileItems = mutableListOf<MediaItem>()

val imageAndVideoItemsSubList = mutableListOf<MediaItem.Event>()
val fileItemsSublist = mutableListOf<MediaItem.Event>()
forEach { item ->
mediaItems.forEach { item ->
when (item) {
is MediaItem.DateSeparator -> {
if (imageAndVideoItemsSubList.isNotEmpty()) {
Expand Down