Merge pull request #4205 from element-hq/feature/bma/mediaNavigation

Add ability to swipe between media when opened from the timeline.
This commit is contained in:
Benoit Marty
2025-01-29 11:51:06 +01:00
committed by GitHub
62 changed files with 1002 additions and 376 deletions

View File

@@ -123,6 +123,7 @@ class MessagesFlowNode @AssistedInject constructor(
@Parcelize
data class MediaViewer(
val mode: MediaViewerEntryPoint.MediaViewerMode,
val eventId: EventId?,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
@@ -248,8 +249,7 @@ class MessagesFlowNode @AssistedInject constructor(
}
is NavTarget.MediaViewer -> {
val params = MediaViewerEntryPoint.Params(
// TODO When we will be able to load a media timeline from a EventId, change mode here (and use a mixed mode?)
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
mode = navTarget.mode,
eventId = navTarget.eventId,
mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource,
@@ -362,6 +362,7 @@ class MessagesFlowNode @AssistedInject constructor(
val navTarget = when (event.content) {
is TimelineItemImageContent -> {
buildMediaViewerNavTarget(
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos,
event = event,
content = event.content,
mediaSource = event.content.mediaSource,
@@ -373,6 +374,7 @@ class MessagesFlowNode @AssistedInject constructor(
if encrypted on certain bridges */
event.content.preferredMediaSource?.let { preferredMediaSource ->
buildMediaViewerNavTarget(
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos,
event = event,
content = event.content,
mediaSource = preferredMediaSource,
@@ -382,6 +384,7 @@ class MessagesFlowNode @AssistedInject constructor(
}
is TimelineItemVideoContent -> {
buildMediaViewerNavTarget(
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos,
event = event,
content = event.content,
mediaSource = event.content.mediaSource,
@@ -390,6 +393,7 @@ class MessagesFlowNode @AssistedInject constructor(
}
is TimelineItemFileContent -> {
buildMediaViewerNavTarget(
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios,
event = event,
content = event.content,
mediaSource = event.content.mediaSource,
@@ -398,6 +402,7 @@ class MessagesFlowNode @AssistedInject constructor(
}
is TimelineItemAudioContent -> {
buildMediaViewerNavTarget(
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios,
event = event,
content = event.content,
mediaSource = event.content.mediaSource,
@@ -426,12 +431,14 @@ class MessagesFlowNode @AssistedInject constructor(
}
private fun buildMediaViewerNavTarget(
mode: MediaViewerEntryPoint.MediaViewerMode,
event: TimelineItem.Event,
content: TimelineItemEventContentWithAttachment,
mediaSource: MediaSource,
thumbnailSource: MediaSource?,
): NavTarget {
return NavTarget.MediaViewer(
mode = mode,
eventId = event.eventId,
mediaInfo = MediaInfo(
filename = content.filename,

View File

@@ -118,8 +118,9 @@ interface MatrixRoom : Closeable {
/**
* Create a new timeline for the media events of the room.
* @param eventId The event to focus on, if any.
*/
suspend fun mediaTimeline(): Result<Timeline>
suspend fun mediaTimeline(eventId: EventId?): Result<Timeline>
fun destroy()

View File

@@ -253,11 +253,21 @@ class RustMatrixRoom(
}
}
override suspend fun mediaTimeline(): Result<Timeline> = withContext(roomDispatcher) {
override suspend fun mediaTimeline(
eventId: EventId?,
): Result<Timeline> = withContext(roomDispatcher) {
val focus = if (eventId != null) {
TimelineFocus.Event(
eventId = eventId.value,
numContextEvents = 50u,
)
} else {
TimelineFocus.Live
}
runCatching {
innerRoom.timelineWithConfiguration(
configuration = TimelineConfiguration(
focus = TimelineFocus.Live,
focus = focus,
allowedMessageTypes = AllowedMessageTypes.Only(
types = listOf(
RoomMessageEventMessageType.FILE,
@@ -270,7 +280,7 @@ class RustMatrixRoom(
dateDividerMode = DateDividerMode.MONTHLY,
)
).let { inner ->
createTimeline(inner, mode = Timeline.Mode.MEDIA)
createTimeline(inner, mode = if (eventId != null) Timeline.Mode.FOCUSED_ON_EVENT else Timeline.Mode.MEDIA)
}
}.onFailure {
if (it is CancellationException) {

View File

@@ -48,6 +48,7 @@ val A_THREAD_ID = ThreadId("\$aThreadId")
val A_THREAD_ID_2 = ThreadId("\$aThreadId2")
val AN_EVENT_ID = EventId("\$anEventId")
val AN_EVENT_ID_2 = EventId("\$anEventId2")
val AN_EVENT_ID_3 = EventId("\$anEventId3")
val A_ROOM_ALIAS = RoomAlias("#alias1:domain")
val A_TRANSACTION_ID = TransactionId("aTransactionId")
val A_DEVICE_ID = DeviceId("ILAKNDNASDLK")

View File

@@ -137,7 +137,7 @@ class FakeMatrixRoom(
private val getMembersResult: (Int) -> Result<List<RoomMember>> = { lambdaError() },
private val timelineFocusedOnEventResult: (EventId) -> Result<Timeline> = { lambdaError() },
private val pinnedEventsTimelineResult: () -> Result<Timeline> = { lambdaError() },
private val mediaTimelineResult: () -> Result<Timeline> = { lambdaError() },
private val mediaTimelineResult: (EventId?) -> Result<Timeline> = { lambdaError() },
private val setSendQueueEnabledLambda: (Boolean) -> Unit = { _: Boolean -> },
private val saveComposerDraftLambda: (ComposerDraft) -> Result<Unit> = { _: ComposerDraft -> Result.success(Unit) },
private val loadComposerDraftLambda: () -> Result<ComposerDraft?> = { Result.success<ComposerDraft?>(null) },
@@ -215,8 +215,8 @@ class FakeMatrixRoom(
pinnedEventsTimelineResult()
}
override suspend fun mediaTimeline(): Result<Timeline> = simulateLongTask {
mediaTimelineResult()
override suspend fun mediaTimeline(eventId: EventId?): Result<Timeline> = simulateLongTask {
mediaTimelineResult(eventId)
}
override suspend fun subscribeToSync() {

View File

@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery
package io.element.android.libraries.mediaviewer.impl.datasource
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.dateformatter.api.DateFormatter
@@ -40,6 +40,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import timber.log.Timber
import javax.inject.Inject

View File

@@ -0,0 +1,45 @@
/*
* 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.datasource
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import javax.inject.Inject
interface FocusedTimelineMediaGalleryDataSourceFactory {
fun createFor(
eventId: EventId,
mediaItem: MediaItem.Event,
): MediaGalleryDataSource
}
@ContributesBinding(RoomScope::class)
class DefaultFocusedTimelineMediaGalleryDataSourceFactory @Inject constructor(
private val room: MatrixRoom,
private val timelineMediaItemsFactory: TimelineMediaItemsFactory,
private val mediaItemsPostProcessor: MediaItemsPostProcessor,
) : FocusedTimelineMediaGalleryDataSourceFactory {
override fun createFor(
eventId: EventId,
mediaItem: MediaItem.Event,
): MediaGalleryDataSource {
return TimelineMediaGalleryDataSource(
room = room,
mediaTimeline = FocusedMediaTimeline(
room = room,
eventId = eventId,
initialMediaItem = mediaItem,
),
timelineMediaItemsFactory = timelineMediaItemsFactory,
mediaItemsPostProcessor = mediaItemsPostProcessor,
)
}
}

View File

@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery
package io.element.android.libraries.mediaviewer.impl.datasource
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.architecture.AsyncData
@@ -15,6 +15,7 @@ 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 io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -39,6 +40,7 @@ interface MediaGalleryDataSource {
@ContributesBinding(RoomScope::class)
class TimelineMediaGalleryDataSource @Inject constructor(
private val room: MatrixRoom,
private val mediaTimeline: MediaTimeline,
private val timelineMediaItemsFactory: TimelineMediaItemsFactory,
private val mediaItemsPostProcessor: MediaItemsPostProcessor,
) : MediaGalleryDataSource {
@@ -48,7 +50,9 @@ class TimelineMediaGalleryDataSource @Inject constructor(
override fun groupedMediaItemsFlow(): Flow<AsyncData<GroupedMediaItems>> = groupedMediaItemsFlow
override fun getLastData(): AsyncData<GroupedMediaItems> = groupedMediaItemsFlow.replayCache.firstOrNull() ?: AsyncData.Uninitialized
override fun getLastData(): AsyncData<GroupedMediaItems> = groupedMediaItemsFlow.replayCache.firstOrNull()
?: mediaTimeline.cache?.let { AsyncData.Success(it) }
?: AsyncData.Uninitialized
private val isStarted = AtomicBoolean(false)
@@ -58,8 +62,13 @@ class TimelineMediaGalleryDataSource @Inject constructor(
return
}
flow {
groupedMediaItemsFlow.emit(AsyncData.Loading())
room.mediaTimeline().fold(
val cache = mediaTimeline.cache
if (cache != null) {
groupedMediaItemsFlow.emit(AsyncData.Success(cache))
} else {
groupedMediaItemsFlow.emit(AsyncData.Loading())
}
mediaTimeline.getTimeline().fold(
{
timeline = it
emit(it)
@@ -78,6 +87,8 @@ class TimelineMediaGalleryDataSource @Inject constructor(
timelineMediaItemsFactory.timelineItems
}.map { timelineItems ->
mediaItemsPostProcessor.process(mediaItems = timelineItems)
}.map {
mediaTimeline.orCache(it)
}.onEach { groupedMediaItems ->
groupedMediaItemsFlow.emit(AsyncData.Success(groupedMediaItems))
}

View File

@@ -5,8 +5,10 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery
package io.element.android.libraries.mediaviewer.impl.datasource
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import kotlinx.collections.immutable.toImmutableList
import javax.inject.Inject

View File

@@ -0,0 +1,97 @@
/*
* 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.datasource
import com.squareup.anvil.annotations.ContributesBinding
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.core.UniqueId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.hasEvent
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
interface MediaTimeline {
suspend fun getTimeline(): Result<Timeline>
val cache: GroupedMediaItems?
fun orCache(data: GroupedMediaItems): GroupedMediaItems
}
/**
* A timeline holder that can be used by the gallery and the media viewer.
* When opening the Media Viewer, if the held timeline knows the Event, it will
* be used, else a FocusedMediaTimeline will be used.
*/
@SingleIn(RoomScope::class)
@ContributesBinding(RoomScope::class)
class LiveMediaTimeline @Inject constructor(
private val room: MatrixRoom,
) : MediaTimeline {
private var timeline: Timeline? = null
private val mutex = Mutex()
override suspend fun getTimeline(): Result<Timeline> = mutex.withLock {
val currentTimeline = timeline
if (currentTimeline == null) {
room.mediaTimeline(null)
.onSuccess { timeline = it }
} else {
Result.success(currentTimeline)
}
}
// No cache for LiveMediaTimeline
override val cache = null
override fun orCache(data: GroupedMediaItems) = data
}
/**
* A class that will provide a media timeline that is focused on a particular event.
*/
class FocusedMediaTimeline(
private val room: MatrixRoom,
private val eventId: EventId,
initialMediaItem: MediaItem.Event,
) : MediaTimeline {
override suspend fun getTimeline(): Result<Timeline> {
return room.mediaTimeline(eventId)
}
override val cache = persistentListOf(
MediaItem.LoadingIndicator(
id = UniqueId("loading_forwards"),
direction = Timeline.PaginationDirection.FORWARDS,
timestamp = 0L,
),
initialMediaItem,
MediaItem.LoadingIndicator(
id = UniqueId("loading_backwards"),
direction = Timeline.PaginationDirection.BACKWARDS,
timestamp = 0L,
),
).let {
GroupedMediaItems(
fileItems = it,
imageAndVideoItems = it,
)
}
override fun orCache(data: GroupedMediaItems): GroupedMediaItems {
return if (data.hasEvent(eventId)) {
data
} else {
cache
}
}
}

View File

@@ -5,13 +5,14 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery
package io.element.android.libraries.mediaviewer.impl.datasource
import io.element.android.libraries.androidutils.diff.DefaultDiffCacheInvalidator
import io.element.android.libraries.androidutils.diff.DiffCacheUpdater
import io.element.android.libraries.androidutils.diff.MutableListDiffCache
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.Flow

View File

@@ -5,12 +5,13 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery
package io.element.android.libraries.mediaviewer.impl.datasource
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import javax.inject.Inject
class VirtualItemFactory @Inject constructor(

View File

@@ -11,6 +11,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
sealed interface MediaGalleryEvents {
data class ChangeMode(val mode: MediaGalleryMode) : MediaGalleryEvents

View File

@@ -21,6 +21,7 @@ import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.mediaviewer.impl.gallery.di.LocalMediaItemPresenterFactories
import io.element.android.libraries.mediaviewer.impl.gallery.di.MediaItemPresenterFactories
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
@ContributesNode(RoomScope::class)
class MediaGalleryNode @AssistedInject constructor(

View File

@@ -32,8 +32,14 @@ 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.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource
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.model.GroupedMediaItems
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.eventId
import io.element.android.libraries.mediaviewer.impl.model.mediaInfo
import io.element.android.libraries.mediaviewer.impl.model.mediaSource
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch

View File

@@ -11,7 +11,7 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.mediaviewer.impl.R
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
import kotlinx.collections.immutable.ImmutableList
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
data class MediaGalleryState(
val roomName: String,
@@ -22,18 +22,6 @@ data class MediaGalleryState(
val eventSink: (MediaGalleryEvents) -> Unit,
)
data class GroupedMediaItems(
val imageAndVideoItems: ImmutableList<MediaItem>,
val fileItems: ImmutableList<MediaItem>,
) {
fun getItems(mode: MediaGalleryMode): ImmutableList<MediaItem> {
return when (mode) {
MediaGalleryMode.Images -> imageAndVideoItems
MediaGalleryMode.Files -> fileItems
}
}
}
enum class MediaGalleryMode(val stringResource: Int) {
Images(R.string.screen_media_browser_list_mode_media),
Files(R.string.screen_media_browser_list_mode_files),

View File

@@ -13,13 +13,15 @@ import io.element.android.libraries.designsystem.components.media.aWaveForm
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemAudio
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemDateSeparator
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemFile
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.impl.gallery.ui.aMediaItemVideo
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemVoice
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemAudio
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemDateSeparator
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemFile
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemLoadingIndicator
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVideo
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVoice
import kotlinx.collections.immutable.toImmutableList
open class MediaGalleryStateProvider : PreviewParameterProvider<MediaGalleryState> {

View File

@@ -72,6 +72,9 @@ import io.element.android.libraries.mediaviewer.impl.gallery.ui.FileItemView
import io.element.android.libraries.mediaviewer.impl.gallery.ui.ImageItemView
import io.element.android.libraries.mediaviewer.impl.gallery.ui.VideoItemView
import io.element.android.libraries.mediaviewer.impl.gallery.ui.VoiceItemView
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.id
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import kotlinx.collections.immutable.ImmutableList
@@ -108,15 +111,15 @@ fun MediaGalleryView(
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
.fillMaxSize(),
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
SingleChoiceSegmentedButtonRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
MediaGalleryMode.entries.forEach { mode ->
SegmentedButton(
@@ -354,8 +357,8 @@ private fun MediaGalleryImageGrid(
) {
LazyVerticalGrid(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
.fillMaxSize()
.padding(horizontal = 16.dp),
columns = GridCells.Adaptive(80.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
@@ -426,9 +429,9 @@ private fun LoadingMoreIndicator(
Timeline.PaginationDirection.FORWARDS -> {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(top = 2.dp)
.height(1.dp)
.fillMaxWidth()
.padding(top = 2.dp)
.height(1.dp)
)
}
Timeline.PaginationDirection.BACKWARDS -> {
@@ -466,9 +469,9 @@ private fun EmptyContent(
OnboardingBackground()
PageTitle(
modifier = Modifier
.fillMaxWidth()
.padding(top = 44.dp)
.padding(24.dp),
.fillMaxWidth()
.padding(top = 44.dp)
.padding(24.dp),
title = stringResource(titleRes),
iconStyle = BigIcon.Style.Default(icon),
subtitle = stringResource(subtitleRes),
@@ -486,9 +489,9 @@ private fun LoadingContent(
OnboardingBackground()
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = 48.dp)
.padding(24.dp),
.fillMaxSize()
.padding(top = 48.dp)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {

View File

@@ -1,85 +0,0 @@
/*
* 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.core.mimetype.MimeTypes.isMimeTypeAudio
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.flowOf
class SingleMediaGalleryDataSource(
private val data: GroupedMediaItems,
) : MediaGalleryDataSource {
override fun start() = Unit
override fun groupedMediaItemsFlow() = flowOf(AsyncData.Success(data))
override fun getLastData(): AsyncData<GroupedMediaItems> = AsyncData.Success(data)
override suspend fun loadMore(direction: Timeline.PaginationDirection) = Unit
override suspend fun deleteItem(eventId: EventId) = Unit
companion object {
fun createFrom(params: MediaViewerEntryPoint.Params) = SingleMediaGalleryDataSource(
data = when {
params.mediaInfo.mimeType.isMimeTypeImage() -> {
MediaItem.Image(
id = UniqueId("dummy"),
eventId = params.eventId,
mediaInfo = params.mediaInfo,
mediaSource = params.mediaSource,
thumbnailSource = params.thumbnailSource,
)
}
params.mediaInfo.mimeType.isMimeTypeVideo() -> {
MediaItem.Video(
id = UniqueId("dummy"),
eventId = params.eventId,
mediaInfo = params.mediaInfo,
mediaSource = params.mediaSource,
thumbnailSource = params.thumbnailSource,
)
}
params.mediaInfo.mimeType.isMimeTypeAudio() -> {
if (params.mediaInfo.waveform == null) {
MediaItem.Audio(
id = UniqueId("dummy"),
eventId = params.eventId,
mediaInfo = params.mediaInfo,
mediaSource = params.mediaSource,
)
} else {
MediaItem.Voice(
id = UniqueId("dummy"),
eventId = params.eventId,
mediaInfo = params.mediaInfo,
mediaSource = params.mediaSource,
)
}
}
else -> {
MediaItem.File(
id = UniqueId("dummy"),
eventId = params.eventId,
mediaInfo = params.mediaInfo,
mediaSource = params.mediaSource,
)
}
}.let { mediaItem ->
GroupedMediaItems(
// Always use imageAndVideoItems, in Single mode, this is the data that will be used
imageAndVideoItems = persistentListOf(mediaItem),
fileItems = persistentListOf(),
)
}
)
}
}

View File

@@ -8,7 +8,7 @@
package io.element.android.libraries.mediaviewer.impl.gallery.di
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.libraries.voiceplayer.api.aVoiceMessageState

View File

@@ -8,7 +8,7 @@
package io.element.android.libraries.mediaviewer.impl.gallery.di
import dagger.MapKey
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import kotlin.reflect.KClass
/**

View File

@@ -15,7 +15,7 @@ import dagger.multibindings.Multibinds
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import javax.inject.Inject
/**

View File

@@ -8,7 +8,7 @@
package io.element.android.libraries.mediaviewer.impl.gallery.di
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
/**
* A factory for a [Presenter] associated with a timeline item.

View File

@@ -31,11 +31,11 @@ import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryNode
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
import io.element.android.libraries.mediaviewer.impl.gallery.eventId
import io.element.android.libraries.mediaviewer.impl.gallery.mediaInfo
import io.element.android.libraries.mediaviewer.impl.gallery.mediaSource
import io.element.android.libraries.mediaviewer.impl.gallery.thumbnailSource
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.eventId
import io.element.android.libraries.mediaviewer.impl.model.mediaInfo
import io.element.android.libraries.mediaviewer.impl.model.mediaSource
import io.element.android.libraries.mediaviewer.impl.model.thumbnailSource
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)

View File

@@ -36,7 +36,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
@Composable
fun AudioItemView(

View File

@@ -18,7 +18,7 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
@Composable
fun DateItemView(

View File

@@ -35,7 +35,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
@Composable
fun FileItemView(

View File

@@ -28,7 +28,8 @@ import coil.compose.AsyncImagePainter
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage
@OptIn(ExperimentalFoundationApi::class)
@Composable

View File

@@ -9,10 +9,8 @@ package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.core.preview.loremIpsum
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemAudio
class MediaItemAudioProvider : PreviewParameterProvider<MediaItem.Audio> {
override val values: Sequence<MediaItem.Audio>
@@ -27,19 +25,3 @@ class MediaItemAudioProvider : PreviewParameterProvider<MediaItem.Audio> {
),
)
}
fun aMediaItemAudio(
id: UniqueId = UniqueId("fileId"),
filename: String = "filename",
caption: String? = null,
): MediaItem.Audio {
return MediaItem.Audio(
id = id,
eventId = null,
mediaInfo = anAudioMediaInfo(
filename = filename,
caption = caption,
),
mediaSource = MediaSource(""),
)
}

View File

@@ -8,8 +8,8 @@
package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemDateSeparator
class MediaItemDateSeparatorProvider : PreviewParameterProvider<MediaItem.DateSeparator> {
override val values: Sequence<MediaItem.DateSeparator>
@@ -18,13 +18,3 @@ class MediaItemDateSeparatorProvider : PreviewParameterProvider<MediaItem.DateSe
aMediaItemDateSeparator(formattedDate = "A long date that should be truncated"),
)
}
fun aMediaItemDateSeparator(
id: UniqueId = UniqueId("dateId"),
formattedDate: String = "October 2024",
): MediaItem.DateSeparator {
return MediaItem.DateSeparator(
id = id,
formattedDate = formattedDate,
)
}

View File

@@ -9,11 +9,8 @@ package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.core.preview.loremIpsum
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemFile
class MediaItemFileProvider : PreviewParameterProvider<MediaItem.File> {
override val values: Sequence<MediaItem.File>
@@ -28,20 +25,3 @@ class MediaItemFileProvider : PreviewParameterProvider<MediaItem.File> {
),
)
}
fun aMediaItemFile(
id: UniqueId = UniqueId("fileId"),
eventId: EventId? = null,
filename: String = "filename",
caption: String? = null,
): MediaItem.File {
return MediaItem.File(
id = id,
eventId = eventId,
mediaInfo = aPdfMediaInfo(
filename = filename,
caption = caption,
),
mediaSource = MediaSource(""),
)
}

View File

@@ -1,32 +0,0 @@
/*
* Copyright 2024 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.ui
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
fun aMediaItemImage(
id: UniqueId = UniqueId("imageId"),
eventId: EventId? = null,
senderId: UserId? = null,
mediaSourceUrl: String = "",
): MediaItem.Image {
return MediaItem.Image(
id = id,
eventId = eventId,
mediaInfo = anImageMediaInfo(
senderId = senderId,
),
mediaSource = MediaSource(mediaSourceUrl),
thumbnailSource = null,
)
}

View File

@@ -1,23 +0,0 @@
/*
* Copyright 2024 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.ui
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
fun aMediaItemLoadingIndicator(
id: UniqueId = UniqueId("loadingId"),
direction: Timeline.PaginationDirection = Timeline.PaginationDirection.BACKWARDS,
): MediaItem.LoadingIndicator {
return MediaItem.LoadingIndicator(
id = id,
direction = direction,
timestamp = 123,
)
}

View File

@@ -8,10 +8,8 @@
package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVideo
class MediaItemVideoProvider : PreviewParameterProvider<MediaItem.Video> {
override val values: Sequence<MediaItem.Video>
@@ -22,19 +20,3 @@ class MediaItemVideoProvider : PreviewParameterProvider<MediaItem.Video> {
),
)
}
fun aMediaItemVideo(
id: UniqueId = UniqueId("videoId"),
mediaSource: MediaSource = MediaSource(""),
duration: String? = "1:23",
): MediaItem.Video {
return MediaItem.Video(
id = id,
eventId = null,
mediaInfo = aVideoMediaInfo(
duration = duration
),
mediaSource = mediaSource,
thumbnailSource = null,
)
}

View File

@@ -9,11 +9,8 @@ package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.core.preview.loremIpsum
import io.element.android.libraries.designsystem.components.media.aWaveForm
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaviewer.api.aVoiceMediaInfo
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVoice
class MediaItemVoiceProvider : PreviewParameterProvider<MediaItem.Voice> {
override val values: Sequence<MediaItem.Voice>
@@ -31,23 +28,3 @@ class MediaItemVoiceProvider : PreviewParameterProvider<MediaItem.Voice> {
),
)
}
fun aMediaItemVoice(
id: UniqueId = UniqueId("fileId"),
filename: String = "filename.ogg",
caption: String? = null,
duration: String? = "1:23",
waveform: List<Float> = aWaveForm(),
): MediaItem.Voice {
return MediaItem.Voice(
id = id,
eventId = null,
mediaInfo = aVoiceMediaInfo(
filename = filename,
caption = caption,
duration = duration,
waveForm = waveform,
),
mediaSource = MediaSource(""),
)
}

View File

@@ -38,7 +38,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
@OptIn(ExperimentalFoundationApi::class)
@Composable

View File

@@ -46,7 +46,8 @@ import io.element.android.libraries.designsystem.theme.components.HorizontalDivi
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVoice
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents
import io.element.android.libraries.voiceplayer.api.VoiceMessageState

View File

@@ -17,9 +17,9 @@ import dagger.assisted.AssistedInject
import dagger.multibindings.IntoMap
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
import io.element.android.libraries.mediaviewer.impl.gallery.di.MediaItemEventContentKey
import io.element.android.libraries.mediaviewer.impl.gallery.di.MediaItemPresenterFactory
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.voiceplayer.api.VoiceMessagePresenterFactory
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import kotlin.time.Duration

View File

@@ -0,0 +1,30 @@
/*
* 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.model
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryMode
import kotlinx.collections.immutable.ImmutableList
data class GroupedMediaItems(
val imageAndVideoItems: ImmutableList<MediaItem>,
val fileItems: ImmutableList<MediaItem>,
) {
fun getItems(mode: MediaGalleryMode): ImmutableList<MediaItem> {
return when (mode) {
MediaGalleryMode.Images -> imageAndVideoItems
MediaGalleryMode.Files -> fileItems
}
}
}
fun GroupedMediaItems.hasEvent(eventId: EventId): Boolean {
return (fileItems + imageAndVideoItems)
.filterIsInstance<MediaItem.Event>()
.any { it.eventId() == eventId }
}

View File

@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery
package io.element.android.libraries.mediaviewer.impl.model
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId

View File

@@ -0,0 +1,128 @@
/*
* 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.model
import io.element.android.libraries.designsystem.components.media.aWaveForm
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
import io.element.android.libraries.mediaviewer.api.aVoiceMediaInfo
import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
fun aMediaItemImage(
id: UniqueId = UniqueId("imageId"),
eventId: EventId? = null,
senderId: UserId? = null,
mediaSourceUrl: String = "",
): MediaItem.Image {
return MediaItem.Image(
id = id,
eventId = eventId,
mediaInfo = anImageMediaInfo(
senderId = senderId,
),
mediaSource = MediaSource(mediaSourceUrl),
thumbnailSource = null,
)
}
fun aMediaItemVideo(
id: UniqueId = UniqueId("videoId"),
mediaSource: MediaSource = MediaSource(""),
duration: String? = "1:23",
): MediaItem.Video {
return MediaItem.Video(
id = id,
eventId = null,
mediaInfo = aVideoMediaInfo(
duration = duration
),
mediaSource = mediaSource,
thumbnailSource = null,
)
}
fun aMediaItemFile(
id: UniqueId = UniqueId("fileId"),
eventId: EventId? = null,
filename: String = "filename",
caption: String? = null,
): MediaItem.File {
return MediaItem.File(
id = id,
eventId = eventId,
mediaInfo = aPdfMediaInfo(
filename = filename,
caption = caption,
),
mediaSource = MediaSource(""),
)
}
fun aMediaItemAudio(
id: UniqueId = UniqueId("fileId"),
eventId: EventId? = null,
filename: String = "filename",
caption: String? = null,
): MediaItem.Audio {
return MediaItem.Audio(
id = id,
eventId = eventId,
mediaInfo = anAudioMediaInfo(
filename = filename,
caption = caption,
),
mediaSource = MediaSource(""),
)
}
fun aMediaItemVoice(
id: UniqueId = UniqueId("fileId"),
filename: String = "filename.ogg",
caption: String? = null,
duration: String? = "1:23",
waveform: List<Float> = aWaveForm(),
): MediaItem.Voice {
return MediaItem.Voice(
id = id,
eventId = null,
mediaInfo = aVoiceMediaInfo(
filename = filename,
caption = caption,
duration = duration,
waveForm = waveform,
),
mediaSource = MediaSource(""),
)
}
fun aMediaItemDateSeparator(
id: UniqueId = UniqueId("dateId"),
formattedDate: String = "October 2024",
): MediaItem.DateSeparator {
return MediaItem.DateSeparator(
id = id,
formattedDate = formattedDate,
)
}
fun aMediaItemLoadingIndicator(
id: UniqueId = UniqueId("loadingId"),
direction: Timeline.PaginationDirection = Timeline.PaginationDirection.BACKWARDS,
): MediaItem.LoadingIndicator {
return MediaItem.LoadingIndicator(
id = id,
direction = direction,
timestamp = 123,
)
}

View File

@@ -18,15 +18,16 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaFile
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint.MediaViewerMode
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.gallery.MediaGalleryDataSource
import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource
import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryMode
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
import io.element.android.libraries.mediaviewer.impl.gallery.eventId
import io.element.android.libraries.mediaviewer.impl.gallery.mediaInfo
import io.element.android.libraries.mediaviewer.impl.gallery.mediaSource
import io.element.android.libraries.mediaviewer.impl.gallery.thumbnailSource
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.eventId
import io.element.android.libraries.mediaviewer.impl.model.mediaInfo
import io.element.android.libraries.mediaviewer.impl.model.mediaSource
import io.element.android.libraries.mediaviewer.impl.model.thumbnailSource
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
@@ -38,16 +39,23 @@ import kotlinx.coroutines.withContext
import timber.log.Timber
class MediaViewerDataSource(
private val galleryMode: MediaGalleryMode,
mode: MediaViewerMode,
private val dispatcher: CoroutineDispatcher,
private val galleryDataSource: MediaGalleryDataSource,
private val mediaLoader: MatrixMediaLoader,
private val localMediaFactory: LocalMediaFactory,
private val systemClock: SystemClock,
private val pagerKeysHandler: PagerKeysHandler,
) {
// List of media files that are currently being loaded
private val mediaFiles: MutableList<MediaFile> = mutableListOf()
private val galleryMode = when (mode) {
MediaViewerMode.SingleMedia,
MediaViewerMode.TimelineImagesAndVideos -> MediaGalleryMode.Images
MediaViewerMode.TimelineFilesAndAudios -> MediaGalleryMode.Files
}
// Map of sourceUrl to local media state
private val localMediaStates: MutableMap<String, MutableState<AsyncData<LocalMedia>>> =
mutableMapOf()
@@ -78,6 +86,7 @@ class MediaViewerDataSource(
MediaViewerPageData.Loading(
direction = Timeline.PaginationDirection.BACKWARDS,
timestamp = systemClock.epochMillis(),
pagerKey = Long.MIN_VALUE,
)
)
}
@@ -108,7 +117,10 @@ class MediaViewerDataSource(
* will be used to render the downloaded media (see [loadMedia] which will update this value).
*/
private fun buildMediaViewerPageList(groupedItems: List<MediaItem>) = buildList {
groupedItems.forEach { mediaItem ->
// Filter out DateSeparator items, we do not need them for the media viewer
val groupedItemsNoDateSeparator = groupedItems.filterNot { it is MediaItem.DateSeparator }
pagerKeysHandler.accept(groupedItemsNoDateSeparator)
groupedItemsNoDateSeparator.forEach { mediaItem ->
when (mediaItem) {
is MediaItem.DateSeparator -> Unit
is MediaItem.Event -> {
@@ -123,6 +135,7 @@ class MediaViewerDataSource(
mediaSource = mediaItem.mediaSource(),
thumbnailSource = mediaItem.thumbnailSource(),
downloadedMedia = localMedia,
pagerKey = pagerKeysHandler.getKey(mediaItem),
)
)
}
@@ -130,6 +143,7 @@ class MediaViewerDataSource(
MediaViewerPageData.Loading(
direction = mediaItem.direction,
timestamp = systemClock.epochMillis(),
pagerKey = pagerKeysHandler.getKey(mediaItem),
)
)
}

View File

@@ -24,9 +24,9 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryMode
import io.element.android.libraries.mediaviewer.impl.gallery.SingleMediaGalleryDataSource
import io.element.android.libraries.mediaviewer.impl.gallery.TimelineMediaGalleryDataSource
import io.element.android.libraries.mediaviewer.impl.datasource.FocusedTimelineMediaGalleryDataSourceFactory
import io.element.android.libraries.mediaviewer.impl.datasource.TimelineMediaGalleryDataSource
import io.element.android.libraries.mediaviewer.impl.model.hasEvent
import io.element.android.services.toolbox.api.systemclock.SystemClock
@ContributesNode(RoomScope::class)
@@ -35,10 +35,12 @@ class MediaViewerNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>,
presenterFactory: MediaViewerPresenter.Factory,
timelineMediaGalleryDataSource: TimelineMediaGalleryDataSource,
focusedTimelineMediaGalleryDataSourceFactory: FocusedTimelineMediaGalleryDataSourceFactory,
mediaLoader: MatrixMediaLoader,
localMediaFactory: LocalMediaFactory,
coroutineDispatchers: CoroutineDispatchers,
systemClock: SystemClock,
pagerKeysHandler: PagerKeysHandler,
) : Node(buildContext, plugins = plugins),
MediaViewerNavigator {
private val inputs = inputs<MediaViewerEntryPoint.Params>()
@@ -62,25 +64,36 @@ class MediaViewerNode @AssistedInject constructor(
private val mediaGallerySource = if (inputs.mode == MediaViewerEntryPoint.MediaViewerMode.SingleMedia) {
SingleMediaGalleryDataSource.createFrom(inputs)
} else {
timelineMediaGalleryDataSource
}
private val galleryMode = when (inputs.mode) {
MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> MediaGalleryMode.Images
MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> MediaGalleryMode.Files
val eventId = inputs.eventId
if (eventId == null) {
// Should not happen
timelineMediaGalleryDataSource
} else {
// Does timelineMediaGalleryDataSource knows the eventId?
val lastData = timelineMediaGalleryDataSource.getLastData().dataOrNull()
val isEventKnown = lastData?.hasEvent(eventId) == true
if (isEventKnown) {
timelineMediaGalleryDataSource
} else {
focusedTimelineMediaGalleryDataSourceFactory.createFor(
eventId = eventId,
mediaItem = inputs.toMediaItem(),
)
}
}
}
private val presenter = presenterFactory.create(
inputs = inputs,
navigator = this,
dataSource = MediaViewerDataSource(
mode = inputs.mode,
dispatcher = coroutineDispatchers.computation,
galleryMode = galleryMode,
galleryDataSource = mediaGallerySource,
mediaLoader = mediaLoader,
localMediaFactory = localMediaFactory,
systemClock = systemClock,
pagerKeysHandler = pagerKeysHandler,
)
)

View File

@@ -28,13 +28,17 @@ data class MediaViewerState(
)
sealed interface MediaViewerPageData {
val pagerKey: Long
data class Failure(
val throwable: Throwable,
override val pagerKey: Long = 0,
) : MediaViewerPageData
data class Loading(
val direction: Timeline.PaginationDirection,
val timestamp: Long,
override val pagerKey: Long,
) : MediaViewerPageData
data class MediaViewerData(
@@ -43,5 +47,6 @@ sealed interface MediaViewerPageData {
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val downloadedMedia: State<AsyncData<LocalMedia>>,
override val pagerKey: Long,
) : MediaViewerPageData
}

View File

@@ -169,6 +169,7 @@ fun aMediaViewerPageDataLoading(
return MediaViewerPageData.Loading(
direction = direction,
timestamp = timestamp,
pagerKey = 0L,
)
}
@@ -182,6 +183,7 @@ fun aMediaViewerPageData(
mediaSource = mediaSource,
thumbnailSource = null,
downloadedMedia = mutableStateOf(downloadedMedia),
pagerKey = 0L,
)
fun aMediaViewerState(

View File

@@ -114,6 +114,7 @@ fun MediaViewerView(
modifier = Modifier,
// Pre-load previous and next pages
beyondViewportPageCount = 1,
key = { index -> state.listData[index].pagerKey },
) { page ->
when (val dataForPage = state.listData[page]) {
is MediaViewerPageData.Failure -> {

View File

@@ -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.viewer
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.eventId
import javax.inject.Inject
/**
* x and y are loading items.
* Capital letters are media items.
* First list emitted
* x F G H y
* indexes will be
* 0 1 2 3 4
* (keyOffset = 0)
* New items added to the end of the list
* x F G H I J K y
* indexes will be
* 0 1 2 3 4 5 6 7
* (keyOffset = 0)
* New items added to the beginning of the list
* x D E F G H I J K y
* indexes will be
* -2 -1 0 1 2 3 4 5 6 7
* (keyOffset = -2)
* loader item vanishes
* D E F G H I J K
* indexes will be
* -1 0 1 2 3 4 5 6
* (keyOffset = -1)
*/
class PagerKeysHandler @Inject constructor() {
private data class Data(
val mediaItems: List<MediaItem>,
val keyOffset: Long,
)
// Will store the list of media items and the key offset of the first item in the list
private var cachedData: Data = Data(emptyList(), 0)
fun accept(mediaItems: List<MediaItem>) {
if (cachedData.mediaItems.isEmpty()) {
cachedData = Data(mediaItems, 0)
} else {
// Search a common item in both lists, i.e. an item with the same eventId
val itemInCacheIndex = cachedData.mediaItems.indexOfFirst { mediaItem ->
mediaItem is MediaItem.Event && mediaItems
.filterIsInstance<MediaItem.Event>()
.any { mediaItem.eventId() == it.eventId() }
}
cachedData = if (itemInCacheIndex == -1) {
// If the item is not found, start with a new cache
Data(mediaItems, 0)
} else {
val cachedItem = cachedData.mediaItems[itemInCacheIndex]
val eventId = (cachedItem as? MediaItem.Event)?.eventId()
if (eventId == null) {
// Should not happen, but in this case, start with a new cache
Data(mediaItems, 0)
} else {
// Search the index of the item in the new list
val itemIndex = mediaItems.indexOfFirst { mediaItem ->
mediaItem is MediaItem.Event && mediaItem.eventId() == eventId
}
if (itemIndex == -1) {
// If the item is not found, start with a new cache
Data(mediaItems, 0)
} else {
// Update the cache with the new list and the new offset
Data(mediaItems, cachedData.keyOffset + itemInCacheIndex - itemIndex.toLong())
}
}
}
}
}
fun getKey(mediaItem: MediaItem): Long {
return cachedData.mediaItems.indexOf(mediaItem) + cachedData.keyOffset
}
}

View File

@@ -0,0 +1,88 @@
/*
* 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.viewer
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.flowOf
class SingleMediaGalleryDataSource(
private val data: GroupedMediaItems,
) : MediaGalleryDataSource {
override fun start() = Unit
override fun groupedMediaItemsFlow() = flowOf(AsyncData.Success(data))
override fun getLastData(): AsyncData<GroupedMediaItems> = AsyncData.Success(data)
override suspend fun loadMore(direction: Timeline.PaginationDirection) = Unit
override suspend fun deleteItem(eventId: EventId) = Unit
companion object {
fun createFrom(params: MediaViewerEntryPoint.Params) = SingleMediaGalleryDataSource(
data = GroupedMediaItems(
// Always use imageAndVideoItems, in Single mode, this is the data that will be used
imageAndVideoItems = persistentListOf(params.toMediaItem()),
fileItems = persistentListOf(),
)
)
}
}
fun MediaViewerEntryPoint.Params.toMediaItem() = when {
mediaInfo.mimeType.isMimeTypeImage() -> {
MediaItem.Image(
id = UniqueId("dummy"),
eventId = eventId,
mediaInfo = mediaInfo,
mediaSource = mediaSource,
thumbnailSource = thumbnailSource,
)
}
mediaInfo.mimeType.isMimeTypeVideo() -> {
MediaItem.Video(
id = UniqueId("dummy"),
eventId = eventId,
mediaInfo = mediaInfo,
mediaSource = mediaSource,
thumbnailSource = thumbnailSource,
)
}
mediaInfo.mimeType.isMimeTypeAudio() -> {
if (mediaInfo.waveform == null) {
MediaItem.Audio(
id = UniqueId("dummy"),
eventId = eventId,
mediaInfo = mediaInfo,
mediaSource = mediaSource,
)
} else {
MediaItem.Voice(
id = UniqueId("dummy"),
eventId = eventId,
mediaInfo = mediaInfo,
mediaSource = mediaSource,
)
}
}
else -> {
MediaItem.File(
id = UniqueId("dummy"),
eventId = eventId,
mediaInfo = mediaInfo,
mediaSource = mediaSource,
)
}
}

View File

@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery
package io.element.android.libraries.mediaviewer.impl.datasource
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
@@ -48,6 +48,7 @@ import io.element.android.libraries.matrix.test.timeline.aProfileChangeMessageCo
import io.element.android.libraries.matrix.test.timeline.aStickerContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList

View File

@@ -0,0 +1,31 @@
/*
* 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.datasource
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultFocusedTimelineMediaGalleryDataSourceFactoryTest {
@Test
fun `createFor should create a TimelineMediaGalleryDataSource`() = runTest {
val sut = DefaultFocusedTimelineMediaGalleryDataSourceFactory(
room = FakeMatrixRoom(),
timelineMediaItemsFactory = createTimelineMediaItemsFactory(),
mediaItemsPostProcessor = MediaItemsPostProcessor(),
)
val result = sut.createFor(
eventId = AN_EVENT_ID,
mediaItem = aMediaItemImage(),
)
assertThat(result).isInstanceOf(TimelineMediaGalleryDataSource::class.java)
}
}

View File

@@ -5,11 +5,12 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery
package io.element.android.libraries.mediaviewer.impl.datasource
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
import io.element.android.tests.testutils.lambda.lambdaError
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow

View File

@@ -0,0 +1,102 @@
/*
* 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.datasource
import com.google.common.truth.Truth.assertThat
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.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
class FocusedMediaTimelineTest {
@Test
fun `check the returned cache data`() {
val media = aMediaItemImage()
val sut = createFocusedMediaTimeline(
initialMediaItem = media,
)
val cache = sut.cache
assertThat(cache.imageAndVideoItems.size).isEqualTo(3)
assertThat(cache.fileItems.size).isEqualTo(3)
assertThat(cache.imageAndVideoItems[1]).isEqualTo(media)
assertThat(cache.fileItems[1]).isEqualTo(media)
}
@Test
fun `when event is not found, the cache is returned`() {
val media = aMediaItemImage(
eventId = AN_EVENT_ID,
)
val sut = createFocusedMediaTimeline(
initialMediaItem = media,
)
val cache = sut.orCache(
GroupedMediaItems(
imageAndVideoItems = persistentListOf(),
fileItems = persistentListOf(),
)
)
assertThat(cache.imageAndVideoItems.size).isEqualTo(3)
assertThat(cache.fileItems.size).isEqualTo(3)
}
@Test
fun `when event is found, the data is returned`() {
val media = aMediaItemImage(
eventId = AN_EVENT_ID,
)
val sut = createFocusedMediaTimeline(
initialMediaItem = media,
)
val cache = sut.orCache(
GroupedMediaItems(
imageAndVideoItems = persistentListOf(media),
fileItems = persistentListOf(),
)
)
assertThat(cache.imageAndVideoItems.size).isEqualTo(1)
assertThat(cache.fileItems).isEmpty()
}
@Test
fun `getTimeline returns the timeline provided by the room`() = runTest {
val mediaTimelineResult = lambdaRecorder<EventId?, Result<Timeline>> {
Result.success(FakeTimeline())
}
val room = FakeMatrixRoom(
mediaTimelineResult = mediaTimelineResult,
)
val sut = createFocusedMediaTimeline(
room = room,
eventId = AN_EVENT_ID,
)
val timeline = sut.getTimeline()
assertThat(timeline.isSuccess).isTrue()
mediaTimelineResult.assertions().isCalledOnce().with(value(AN_EVENT_ID))
}
private fun createFocusedMediaTimeline(
room: MatrixRoom = FakeMatrixRoom(),
eventId: EventId = AN_EVENT_ID,
initialMediaItem: MediaItem.Event = aMediaItemImage(),
) = FocusedMediaTimeline(
room = room,
eventId = eventId,
initialMediaItem = initialMediaItem,
)
}

View File

@@ -0,0 +1,54 @@
/*
* 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.datasource
import com.google.common.truth.Truth.assertThat
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.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.test.runTest
import org.junit.Test
class LiveMediaTimelineTest {
@Test
fun `LiveMediaTimeline cache is always null`() = runTest {
val sut = createLiveMediaTimeline()
assertThat<GroupedMediaItems?>(sut.cache).isNull()
}
@Test
fun `getTimeline returns the timeline provided by the room, then from cache`() = runTest {
val mediaTimelineResult = lambdaRecorder<EventId?, Result<Timeline>> {
Result.success(FakeTimeline())
}
val room = FakeMatrixRoom(
mediaTimelineResult = mediaTimelineResult,
)
val sut = createLiveMediaTimeline(
room = room,
)
val timeline = sut.getTimeline()
assertThat(timeline.isSuccess).isTrue()
mediaTimelineResult.assertions().isCalledOnce().with(value(null))
val timeline2 = sut.getTimeline()
assertThat(timeline2.isSuccess).isTrue()
// No called another time
mediaTimelineResult.assertions().isCalledOnce()
}
private fun createLiveMediaTimeline(
room: MatrixRoom = FakeMatrixRoom(),
) = LiveMediaTimeline(
room = room,
)
}

View File

@@ -5,17 +5,19 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery
package io.element.android.libraries.mediaviewer.impl.datasource
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemAudio
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemDateSeparator
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemFile
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.impl.gallery.ui.aMediaItemVideo
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemVoice
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemAudio
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemDateSeparator
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemFile
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemLoadingIndicator
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVideo
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVoice
import kotlinx.collections.immutable.toImmutableList
import org.junit.Test

View File

@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery
package io.element.android.libraries.mediaviewer.impl.datasource
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
@@ -34,6 +34,8 @@ import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
@@ -260,18 +262,21 @@ class TimelineMediaGalleryDataSourceTest {
): TimelineMediaGalleryDataSource {
return TimelineMediaGalleryDataSource(
room = room,
timelineMediaItemsFactory = TimelineMediaItemsFactory(
dispatchers = testCoroutineDispatchers(),
virtualItemFactory = VirtualItemFactory(
dateFormatter = FakeDateFormatter(),
),
eventItemFactory = EventItemFactory(
fileSizeFormatter = FakeFileSizeFormatter(),
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
dateFormatter = FakeDateFormatter(),
),
),
mediaTimeline = LiveMediaTimeline(room),
timelineMediaItemsFactory = createTimelineMediaItemsFactory(),
mediaItemsPostProcessor = MediaItemsPostProcessor(),
)
}
}
fun TestScope.createTimelineMediaItemsFactory() = TimelineMediaItemsFactory(
dispatchers = testCoroutineDispatchers(),
virtualItemFactory = VirtualItemFactory(
dateFormatter = FakeDateFormatter(),
),
eventItemFactory = EventItemFactory(
fileSizeFormatter = FakeFileSizeFormatter(),
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
dateFormatter = FakeDateFormatter(),
),
)

View File

@@ -21,8 +21,10 @@ import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.mediaviewer.impl.datasource.FakeMediaGalleryDataSource
import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
import io.element.android.tests.testutils.WarmUpRule

View File

@@ -0,0 +1,32 @@
/*
* 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.model
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.AN_EVENT_ID_3
import kotlinx.collections.immutable.persistentListOf
import org.junit.Test
class GroupedMediaItemsTest {
@Test
fun `hasEvent returns the expected value`() {
val sut = GroupedMediaItems(
imageAndVideoItems = persistentListOf(
aMediaItemImage(eventId = AN_EVENT_ID),
),
fileItems = persistentListOf(
aMediaItemAudio(eventId = AN_EVENT_ID_2),
),
)
assertThat(sut.hasEvent(AN_EVENT_ID)).isTrue()
assertThat(sut.hasEvent(AN_EVENT_ID_2)).isTrue()
assertThat(sut.hasEvent(AN_EVENT_ID_3)).isFalse()
}
}

View File

@@ -17,15 +17,15 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint.MediaViewerMode
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.mediaviewer.impl.gallery.FakeMediaGalleryDataSource
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.datasource.FakeMediaGalleryDataSource
import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource
import io.element.android.libraries.mediaviewer.impl.gallery.aGroupedMediaItems
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemDateSeparator
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemFile
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.impl.model.aMediaItemDateSeparator
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemFile
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemLoadingIndicator
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
@@ -122,10 +122,12 @@ class MediaViewerDataSourceTest {
MediaViewerPageData.Loading(
direction = Timeline.PaginationDirection.BACKWARDS,
timestamp = A_FAKE_TIMESTAMP,
pagerKey = 0L,
),
MediaViewerPageData.Loading(
direction = Timeline.PaginationDirection.FORWARDS,
timestamp = A_FAKE_TIMESTAMP,
pagerKey = 1L,
),
)
}
@@ -135,7 +137,7 @@ class MediaViewerDataSourceTest {
fun `test dataFlow with data galleryMode image`() = runTest {
val galleryDataSource = FakeMediaGalleryDataSource()
val sut = createMediaViewerDataSource(
galleryMode = MediaGalleryMode.Images,
mode = MediaViewerMode.TimelineImagesAndVideos,
galleryDataSource = galleryDataSource,
)
sut.dataFlow().test {
@@ -157,7 +159,7 @@ class MediaViewerDataSourceTest {
fun `test dataFlow with data galleryMode files`() = runTest {
val galleryDataSource = FakeMediaGalleryDataSource()
val sut = createMediaViewerDataSource(
galleryMode = MediaGalleryMode.Files,
mode = MediaViewerMode.TimelineFilesAndAudios,
galleryDataSource = galleryDataSource,
)
sut.dataFlow().test {
@@ -263,16 +265,17 @@ class MediaViewerDataSourceTest {
}
private fun TestScope.createMediaViewerDataSource(
galleryMode: MediaGalleryMode = MediaGalleryMode.Images,
mode: MediaViewerMode = MediaViewerMode.TimelineImagesAndVideos,
galleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource(),
mediaLoader: MatrixMediaLoader = FakeMatrixMediaLoader(),
localMediaFactory: LocalMediaFactory = FakeLocalMediaFactory(mockMediaUrl),
) = MediaViewerDataSource(
galleryMode = galleryMode,
mode = mode,
dispatcher = testCoroutineDispatchers().computation,
galleryDataSource = galleryDataSource,
mediaLoader = mediaLoader,
localMediaFactory = localMediaFactory,
systemClock = FakeSystemClock(),
pagerKeysHandler = PagerKeysHandler(),
)
}

View File

@@ -29,13 +29,12 @@ 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.datasource.FakeMediaGalleryDataSource
import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource
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.impl.model.GroupedMediaItems
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage
import io.element.android.libraries.mediaviewer.impl.model.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
@@ -782,16 +781,13 @@ class MediaViewerPresenterTest {
),
navigator = mediaViewerNavigator,
dataSource = MediaViewerDataSource(
galleryMode = when (mode) {
MediaViewerEntryPoint.MediaViewerMode.SingleMedia -> MediaGalleryMode.Images
MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> MediaGalleryMode.Images
MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> MediaGalleryMode.Files
},
mode = mode,
dispatcher = testCoroutineDispatchers().computation,
galleryDataSource = mediaGalleryDataSource,
mediaLoader = matrixMediaLoader,
localMediaFactory = localMediaFactory,
systemClock = FakeSystemClock(),
pagerKeysHandler = PagerKeysHandler(),
),
room = room,
localMediaActions = localMediaActions,

View File

@@ -0,0 +1,76 @@
/*
* 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.viewer
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemLoadingIndicator
import org.junit.Test
class PagerKeysHandlerTest {
private val image1 = aMediaItemImage(
eventId = AN_EVENT_ID,
)
private val image2 = aMediaItemImage(
eventId = AN_EVENT_ID_2,
)
private val aBackwardLoadingIndicator = aMediaItemLoadingIndicator(
direction = Timeline.PaginationDirection.BACKWARDS
)
private val aForwardLoadingIndicator = aMediaItemLoadingIndicator(
direction = Timeline.PaginationDirection.FORWARDS
)
@Test
fun `when new items are inserted after existing items, keys are not shifted`() {
val sut = PagerKeysHandler()
sut.accept(listOf(aBackwardLoadingIndicator, image1, aForwardLoadingIndicator))
assertThat(sut.getKey(aBackwardLoadingIndicator)).isEqualTo(0)
assertThat(sut.getKey(image1)).isEqualTo(1)
assertThat(sut.getKey(aForwardLoadingIndicator)).isEqualTo(2)
sut.accept(listOf(aBackwardLoadingIndicator, image1, image2, aForwardLoadingIndicator))
assertThat(sut.getKey(aBackwardLoadingIndicator)).isEqualTo(0)
assertThat(sut.getKey(image1)).isEqualTo(1)
assertThat(sut.getKey(image2)).isEqualTo(2)
assertThat(sut.getKey(aForwardLoadingIndicator)).isEqualTo(3)
}
@Test
fun `when new items are inserted before existing items, keys are not shifted`() {
val sut = PagerKeysHandler()
sut.accept(listOf(aBackwardLoadingIndicator, image1, aForwardLoadingIndicator))
assertThat(sut.getKey(aBackwardLoadingIndicator)).isEqualTo(0)
assertThat(sut.getKey(image1)).isEqualTo(1)
assertThat(sut.getKey(aForwardLoadingIndicator)).isEqualTo(2)
sut.accept(listOf(aBackwardLoadingIndicator, image2, image1, aForwardLoadingIndicator))
assertThat(sut.getKey(aBackwardLoadingIndicator)).isEqualTo(-1)
assertThat(sut.getKey(image2)).isEqualTo(0)
assertThat(sut.getKey(image1)).isEqualTo(1)
assertThat(sut.getKey(aForwardLoadingIndicator)).isEqualTo(2)
// Accepting the same list should not change the keys
sut.accept(listOf(aBackwardLoadingIndicator, image2, image1, aForwardLoadingIndicator))
assertThat(sut.getKey(aBackwardLoadingIndicator)).isEqualTo(-1)
assertThat(sut.getKey(image2)).isEqualTo(0)
assertThat(sut.getKey(image1)).isEqualTo(1)
assertThat(sut.getKey(aForwardLoadingIndicator)).isEqualTo(2)
}
@Test
fun `when loaders are removed, keys are not shifted`() {
val sut = PagerKeysHandler()
sut.accept(listOf(aBackwardLoadingIndicator, image1, aForwardLoadingIndicator))
assertThat(sut.getKey(aBackwardLoadingIndicator)).isEqualTo(0)
assertThat(sut.getKey(image1)).isEqualTo(1)
assertThat(sut.getKey(aForwardLoadingIndicator)).isEqualTo(2)
sut.accept(listOf(image1))
assertThat(sut.getKey(image1)).isEqualTo(1)
}
}

View File

@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery
package io.element.android.libraries.mediaviewer.impl.viewer
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
@@ -22,8 +22,10 @@ import io.element.android.libraries.mediaviewer.api.aVoiceMediaInfo
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemFile
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage
import io.element.android.libraries.mediaviewer.impl.gallery.aGroupedMediaItems
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemFile
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule

View File

@@ -136,6 +136,7 @@ class KonsistClassNameTest {
"Enterprise",
"Fdroid",
"FileExtensionExtractor",
"LiveMediaTimeline",
"KeyStore",
"Matrix",
"Noop",