mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-05 04:15:12 +00:00
Merge pull request #7112 from element-hq/feature/bma/galleryMessages
Support gallery messages
This commit is contained in:
+4
@@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageT
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||
@@ -104,6 +105,9 @@ class DefaultPinnedMessagesBannerFormatter(
|
||||
is OtherMessageType -> {
|
||||
messageType.body
|
||||
}
|
||||
is GalleryMessageType -> {
|
||||
messageType.body.prefixWith(CommonStrings.common_gallery)
|
||||
}
|
||||
is NoticeMessageType -> {
|
||||
messageType.body
|
||||
}
|
||||
|
||||
+4
@@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent
|
||||
@@ -167,6 +168,9 @@ class DefaultRoomLatestEventFormatter(
|
||||
is OtherMessageType -> {
|
||||
messageType.body
|
||||
}
|
||||
is GalleryMessageType -> {
|
||||
messageType.body.prefixWith(sp.getString(CommonStrings.common_gallery))
|
||||
}
|
||||
is NoticeMessageType -> {
|
||||
messageType.body
|
||||
}
|
||||
|
||||
+4
@@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventTimeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
|
||||
@@ -141,6 +142,7 @@ class DefaultPinnedMessagesBannerFormatterTest : RobolectricTest() {
|
||||
AudioMessageType(body, null, null, MediaSource("url"), null),
|
||||
VoiceMessageType(body, null, null, MediaSource("url"), null, null),
|
||||
ImageMessageType(body, null, null, MediaSource("url"), null),
|
||||
GalleryMessageType(body, null, emptyList()),
|
||||
StickerMessageType(body, null, null, MediaSource("url"), null),
|
||||
FileMessageType(body, null, null, MediaSource("url"), null),
|
||||
LocationMessageType(body, "geo:1,2", null, null),
|
||||
@@ -163,6 +165,7 @@ class DefaultPinnedMessagesBannerFormatterTest : RobolectricTest() {
|
||||
is VideoMessageType,
|
||||
is AudioMessageType,
|
||||
is ImageMessageType,
|
||||
is GalleryMessageType,
|
||||
is StickerMessageType,
|
||||
is FileMessageType,
|
||||
is LocationMessageType -> AnnotatedString::class.java
|
||||
@@ -181,6 +184,7 @@ class DefaultPinnedMessagesBannerFormatterTest : RobolectricTest() {
|
||||
is AudioMessageType -> "Audio: Shared body"
|
||||
is VoiceMessageType -> "Voice message"
|
||||
is ImageMessageType -> "Image: Shared body"
|
||||
is GalleryMessageType -> "Gallery: Shared body"
|
||||
is StickerMessageType -> "Sticker: Shared body"
|
||||
is FileMessageType -> "File: Shared body"
|
||||
is LocationMessageType -> "Shared location: Shared body"
|
||||
|
||||
+6
@@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
|
||||
@@ -195,6 +196,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
|
||||
AudioMessageType(body, null, null, MediaSource("url"), null),
|
||||
VoiceMessageType(body, null, null, MediaSource("url"), null, null),
|
||||
ImageMessageType(body, null, null, MediaSource("url"), null),
|
||||
GalleryMessageType(body, null, emptyList()),
|
||||
StickerMessageType(body, null, null, MediaSource("url"), null),
|
||||
FileMessageType(body, null, null, MediaSource("url"), null),
|
||||
LocationMessageType(body, "geo:1,2", null, null),
|
||||
@@ -227,6 +229,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
|
||||
is AudioMessageType -> "Audio: Shared body"
|
||||
is VoiceMessageType -> "Voice message"
|
||||
is ImageMessageType -> "Image: Shared body"
|
||||
is GalleryMessageType -> "Gallery: Shared body"
|
||||
is StickerMessageType -> "Sticker: Shared body"
|
||||
is FileMessageType -> "File: Shared body"
|
||||
is LocationMessageType -> "Shared location"
|
||||
@@ -247,6 +250,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
|
||||
is TextMessageType -> false
|
||||
is NoticeMessageType -> false
|
||||
is OtherMessageType -> false
|
||||
is GalleryMessageType -> true
|
||||
}
|
||||
if (shouldCreateAnnotatedString) {
|
||||
assertWithMessage("$type doesn't produce an AnnotatedString")
|
||||
@@ -264,6 +268,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
|
||||
is AudioMessageType -> "$expectedPrefix: Audio: Shared body"
|
||||
is VoiceMessageType -> "$expectedPrefix: Voice message"
|
||||
is ImageMessageType -> "$expectedPrefix: Image: Shared body"
|
||||
is GalleryMessageType -> "$expectedPrefix: Gallery: Shared body"
|
||||
is StickerMessageType -> "$expectedPrefix: Sticker: Shared body"
|
||||
is FileMessageType -> "$expectedPrefix: File: Shared body"
|
||||
is LocationMessageType -> "$expectedPrefix: Shared location"
|
||||
@@ -284,6 +289,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
|
||||
is TextMessageType -> true
|
||||
is NoticeMessageType -> true
|
||||
is OtherMessageType -> true
|
||||
is GalleryMessageType -> true
|
||||
}
|
||||
if (shouldCreateAnnotatedString) {
|
||||
assertWithMessage("$type doesn't produce an AnnotatedString")
|
||||
|
||||
+7
@@ -137,4 +137,11 @@ enum class FeatureFlags(
|
||||
defaultValue = { false },
|
||||
isFinished = false,
|
||||
),
|
||||
SendGalleryMessages(
|
||||
key = "feature.send_gallery_messages",
|
||||
title = "Send gallery messages",
|
||||
description = "Allow sending multiple media items in a single message.",
|
||||
defaultValue = { false },
|
||||
isFinished = false,
|
||||
),
|
||||
}
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.matrix.api.media
|
||||
|
||||
import java.io.File
|
||||
|
||||
sealed interface GalleryItemInfo {
|
||||
val file: File
|
||||
|
||||
data class Image(
|
||||
override val file: File,
|
||||
val imageInfo: ImageInfo,
|
||||
val thumbnailFile: File?,
|
||||
) : GalleryItemInfo
|
||||
|
||||
data class Video(
|
||||
override val file: File,
|
||||
val videoInfo: VideoInfo,
|
||||
val thumbnailFile: File?,
|
||||
) : GalleryItemInfo
|
||||
|
||||
data class Audio(
|
||||
override val file: File,
|
||||
val audioInfo: AudioInfo,
|
||||
) : GalleryItemInfo
|
||||
|
||||
data class MediaFile(
|
||||
override val file: File,
|
||||
val fileInfo: FileInfo,
|
||||
) : GalleryItemInfo
|
||||
}
|
||||
+8
@@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.core.TransactionId
|
||||
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||
import io.element.android.libraries.matrix.api.media.GalleryItemInfo
|
||||
import io.element.android.libraries.matrix.api.media.ImageInfo
|
||||
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
|
||||
import io.element.android.libraries.matrix.api.media.VideoInfo
|
||||
@@ -157,6 +158,13 @@ interface Timeline : AutoCloseable {
|
||||
inReplyToEventId: EventId?,
|
||||
): Result<MediaUploadHandler>
|
||||
|
||||
suspend fun sendGallery(
|
||||
items: List<GalleryItemInfo>,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
inReplyToEventId: EventId?,
|
||||
): Result<MediaUploadHandler>
|
||||
|
||||
suspend fun redactEvent(eventOrTransactionId: EventOrTransactionId, reason: String?): Result<Unit>
|
||||
|
||||
suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result<Boolean>
|
||||
|
||||
+15
@@ -102,6 +102,21 @@ data class TextMessageType(
|
||||
val formatted: FormattedBody?
|
||||
) : MessageType
|
||||
|
||||
data class GalleryMessageType(
|
||||
val body: String,
|
||||
val formatted: FormattedBody?,
|
||||
val items: List<GalleryItemType>,
|
||||
) : MessageType
|
||||
|
||||
@Immutable
|
||||
sealed interface GalleryItemType {
|
||||
data class Image(val content: ImageMessageType) : GalleryItemType
|
||||
data class Audio(val content: AudioMessageType) : GalleryItemType
|
||||
data class Video(val content: VideoMessageType) : GalleryItemType
|
||||
data class File(val content: FileMessageType) : GalleryItemType
|
||||
data class Other(val itemType: String, val body: String) : GalleryItemType
|
||||
}
|
||||
|
||||
data class OtherMessageType(
|
||||
val msgType: String,
|
||||
val body: String,
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.matrix.impl.media
|
||||
|
||||
import io.element.android.libraries.matrix.api.media.GalleryItemInfo
|
||||
import org.matrix.rustcomponents.sdk.GalleryItemInfo as RustGalleryItemInfo
|
||||
import org.matrix.rustcomponents.sdk.UploadSource as RustUploadSource
|
||||
|
||||
fun GalleryItemInfo.map(): RustGalleryItemInfo = when (this) {
|
||||
is GalleryItemInfo.Image -> {
|
||||
RustGalleryItemInfo.Image(
|
||||
imageInfo = imageInfo.map(),
|
||||
source = RustUploadSource.File(file.path),
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
thumbnailSource = thumbnailFile?.path?.let(RustUploadSource::File),
|
||||
)
|
||||
}
|
||||
is GalleryItemInfo.Video -> {
|
||||
RustGalleryItemInfo.Video(
|
||||
videoInfo = videoInfo.map(),
|
||||
source = RustUploadSource.File(file.path),
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
thumbnailSource = thumbnailFile?.path?.let(RustUploadSource::File),
|
||||
)
|
||||
}
|
||||
is GalleryItemInfo.Audio -> {
|
||||
RustGalleryItemInfo.Audio(
|
||||
audioInfo = audioInfo.map(),
|
||||
source = RustUploadSource.File(file.path),
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
)
|
||||
}
|
||||
is GalleryItemInfo.MediaFile -> {
|
||||
RustGalleryItemInfo.File(
|
||||
fileInfo = fileInfo.map(),
|
||||
source = RustUploadSource.File(file.path),
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.matrix.impl.media
|
||||
|
||||
import io.element.android.libraries.androidutils.file.safeDelete
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
|
||||
import org.matrix.rustcomponents.sdk.SendGalleryJoinHandle
|
||||
import java.io.File
|
||||
|
||||
class GalleryMediaUploadHandlerImpl(
|
||||
private val filesToUpload: List<File>,
|
||||
private val sendGalleryJoinHandle: SendGalleryJoinHandle,
|
||||
) : MediaUploadHandler {
|
||||
override suspend fun await(): Result<Unit> =
|
||||
runCatchingExceptions {
|
||||
sendGalleryJoinHandle.join()
|
||||
}
|
||||
.also { cleanUpFiles() }
|
||||
|
||||
override fun cancel() {
|
||||
sendGalleryJoinHandle.cancel()
|
||||
cleanUpFiles()
|
||||
}
|
||||
|
||||
private fun cleanUpFiles() {
|
||||
filesToUpload.forEach { file -> file.safeDelete() }
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -97,7 +97,10 @@ private fun MessageLikeEventContent.toContent(senderId: UserId): NotificationCon
|
||||
is MessageLikeEventContent.ReactionContent -> NotificationContent.MessageLike.ReactionContent(relatedEventId)
|
||||
MessageLikeEventContent.RoomEncrypted -> NotificationContent.MessageLike.RoomEncrypted
|
||||
is MessageLikeEventContent.RoomMessage -> {
|
||||
NotificationContent.MessageLike.RoomMessage(senderId, EventMessageMapper().mapMessageType(messageType))
|
||||
NotificationContent.MessageLike.RoomMessage(
|
||||
senderId,
|
||||
EventMessageMapper().mapMessageType(messageType)
|
||||
)
|
||||
}
|
||||
is MessageLikeEventContent.RoomRedaction -> NotificationContent.MessageLike.RoomRedaction(
|
||||
redactedEventId = redactedEventId?.let(::EventId),
|
||||
|
||||
+1
@@ -219,6 +219,7 @@ class JoinedRustRoom(
|
||||
RoomMessageEventMessageType.IMAGE,
|
||||
RoomMessageEventMessageType.VIDEO,
|
||||
RoomMessageEventMessageType.AUDIO,
|
||||
RoomMessageEventMessageType.GALLERY,
|
||||
)
|
||||
)
|
||||
is CreateTimelineParams.Focused,
|
||||
|
||||
+39
@@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||
import io.element.android.libraries.matrix.api.media.GalleryItemInfo
|
||||
import io.element.android.libraries.matrix.api.media.ImageInfo
|
||||
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
|
||||
import io.element.android.libraries.matrix.api.media.VideoInfo
|
||||
@@ -30,6 +31,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineException
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
||||
import io.element.android.libraries.matrix.impl.media.GalleryMediaUploadHandlerImpl
|
||||
import io.element.android.libraries.matrix.impl.media.MediaUploadHandlerImpl
|
||||
import io.element.android.libraries.matrix.impl.media.map
|
||||
import io.element.android.libraries.matrix.impl.poll.toInner
|
||||
@@ -67,6 +69,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.matrix.rustcomponents.sdk.EditedContent
|
||||
import org.matrix.rustcomponents.sdk.FormattedBody
|
||||
import org.matrix.rustcomponents.sdk.GalleryUploadParameters
|
||||
import org.matrix.rustcomponents.sdk.MessageFormat
|
||||
import org.matrix.rustcomponents.sdk.PollData
|
||||
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
|
||||
@@ -535,6 +538,42 @@ class RustTimeline(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendGallery(
|
||||
items: List<GalleryItemInfo>,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
inReplyToEventId: EventId?,
|
||||
): Result<MediaUploadHandler> {
|
||||
Timber.d("Sending gallery with ${items.size} items")
|
||||
val allFiles = items.flatMap { item ->
|
||||
when (item) {
|
||||
is GalleryItemInfo.Image -> listOfNotNull(item.file, item.thumbnailFile)
|
||||
is GalleryItemInfo.Video -> listOfNotNull(item.file, item.thumbnailFile)
|
||||
is GalleryItemInfo.Audio -> listOf(item.file)
|
||||
is GalleryItemInfo.MediaFile -> listOf(item.file)
|
||||
}
|
||||
}
|
||||
return sendGalleryAttachment(allFiles) {
|
||||
inner.sendGallery(
|
||||
params = GalleryUploadParameters(
|
||||
caption = caption,
|
||||
formattedCaption = formattedCaption?.let {
|
||||
FormattedBody(body = it, format = MessageFormat.Html)
|
||||
},
|
||||
mentions = null,
|
||||
inReplyTo = inReplyToEventId?.value,
|
||||
),
|
||||
itemInfos = items.map { it.map() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendGalleryAttachment(files: List<File>, handle: () -> org.matrix.rustcomponents.sdk.SendGalleryJoinHandle): Result<MediaUploadHandler> {
|
||||
return runCatchingExceptions {
|
||||
GalleryMediaUploadHandlerImpl(files, handle())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun createPoll(
|
||||
question: String,
|
||||
answers: List<String>,
|
||||
|
||||
+61
-5
@@ -13,6 +13,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageT
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryItemType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
|
||||
@@ -31,12 +33,10 @@ import org.matrix.rustcomponents.sdk.MessageType
|
||||
import org.matrix.rustcomponents.sdk.MsgLikeKind
|
||||
import org.matrix.rustcomponents.sdk.use
|
||||
import org.matrix.rustcomponents.sdk.FormattedBody as RustFormattedBody
|
||||
import org.matrix.rustcomponents.sdk.GalleryItemType as RustGalleryItemType
|
||||
import org.matrix.rustcomponents.sdk.MessageFormat as RustMessageFormat
|
||||
import org.matrix.rustcomponents.sdk.MessageType as RustMessageType
|
||||
|
||||
// https://github.com/Johennes/matrix-spec-proposals/blob/johannes/msgtype-galleries/proposals/4274-inline-media-galleries.md#unstable-prefix
|
||||
private const val MSG_TYPE_GALLERY_UNSTABLE = "dm.filament.gallery"
|
||||
|
||||
class EventMessageMapper {
|
||||
private val inReplyToMapper by lazy { InReplyToMapper(TimelineEventContentMapper()) }
|
||||
|
||||
@@ -124,8 +124,64 @@ class EventMessageMapper {
|
||||
OtherMessageType(type.msgtype, type.body)
|
||||
}
|
||||
is MessageType.Gallery -> {
|
||||
// TODO expose the GalleryType.
|
||||
OtherMessageType(MSG_TYPE_GALLERY_UNSTABLE, type.content.body)
|
||||
GalleryMessageType(
|
||||
body = type.content.body,
|
||||
formatted = type.content.formatted?.map(),
|
||||
items = type.content.itemtypes.map { mapGalleryItemType(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapGalleryItemType(type: RustGalleryItemType): GalleryItemType = when (type) {
|
||||
is RustGalleryItemType.Image -> {
|
||||
GalleryItemType.Image(
|
||||
content = ImageMessageType(
|
||||
filename = type.content.filename,
|
||||
caption = type.content.caption,
|
||||
formattedCaption = type.content.formattedCaption?.map(),
|
||||
source = type.content.source.map(),
|
||||
info = type.content.info?.map(),
|
||||
)
|
||||
)
|
||||
}
|
||||
is RustGalleryItemType.Audio -> {
|
||||
GalleryItemType.Audio(
|
||||
content = AudioMessageType(
|
||||
filename = type.content.filename,
|
||||
caption = type.content.caption,
|
||||
formattedCaption = type.content.formattedCaption?.map(),
|
||||
source = type.content.source.map(),
|
||||
info = type.content.info?.map(),
|
||||
)
|
||||
)
|
||||
}
|
||||
is RustGalleryItemType.Video -> {
|
||||
GalleryItemType.Video(
|
||||
content = VideoMessageType(
|
||||
filename = type.content.filename,
|
||||
caption = type.content.caption,
|
||||
formattedCaption = type.content.formattedCaption?.map(),
|
||||
source = type.content.source.map(),
|
||||
info = type.content.info?.map(),
|
||||
)
|
||||
)
|
||||
}
|
||||
is RustGalleryItemType.File -> {
|
||||
GalleryItemType.File(
|
||||
content = FileMessageType(
|
||||
filename = type.content.filename,
|
||||
caption = type.content.caption,
|
||||
formattedCaption = type.content.formattedCaption?.map(),
|
||||
source = type.content.source.map(),
|
||||
info = type.content.info?.map(),
|
||||
)
|
||||
)
|
||||
}
|
||||
is RustGalleryItemType.Other -> {
|
||||
GalleryItemType.Other(
|
||||
itemType = type.itemtype,
|
||||
body = type.body,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+105
@@ -8,12 +8,26 @@
|
||||
|
||||
package io.element.android.libraries.matrix.impl.fixtures.factories
|
||||
|
||||
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiMediaSource
|
||||
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||
import org.matrix.rustcomponents.sdk.AudioInfo
|
||||
import org.matrix.rustcomponents.sdk.AudioMessageContent
|
||||
import org.matrix.rustcomponents.sdk.FileInfo
|
||||
import org.matrix.rustcomponents.sdk.FileMessageContent
|
||||
import org.matrix.rustcomponents.sdk.FormattedBody
|
||||
import org.matrix.rustcomponents.sdk.GalleryItemType
|
||||
import org.matrix.rustcomponents.sdk.GalleryMessageContent
|
||||
import org.matrix.rustcomponents.sdk.ImageInfo
|
||||
import org.matrix.rustcomponents.sdk.ImageMessageContent
|
||||
import org.matrix.rustcomponents.sdk.MediaSource
|
||||
import org.matrix.rustcomponents.sdk.MessageLikeEventContent
|
||||
import org.matrix.rustcomponents.sdk.MessageType
|
||||
import org.matrix.rustcomponents.sdk.TextMessageContent
|
||||
import org.matrix.rustcomponents.sdk.TimelineEventContent
|
||||
import org.matrix.rustcomponents.sdk.UnstableAudioDetailsContent
|
||||
import org.matrix.rustcomponents.sdk.UnstableVoiceContent
|
||||
import org.matrix.rustcomponents.sdk.VideoInfo
|
||||
import org.matrix.rustcomponents.sdk.VideoMessageContent
|
||||
|
||||
internal fun aRustTimelineEventContentMessageLike(
|
||||
content: MessageLikeEventContent = aRustMessageLikeEventContentRoomMessage(),
|
||||
@@ -42,3 +56,94 @@ internal fun aRustTextMessageContent(
|
||||
body = body,
|
||||
formatted = formatted,
|
||||
)
|
||||
|
||||
internal fun aRustMessageTypeGallery(
|
||||
content: GalleryMessageContent = aRustGalleryMessageContent(),
|
||||
) = MessageType.Gallery(content = content)
|
||||
|
||||
internal fun aRustGalleryMessageContent(
|
||||
body: String = "A gallery",
|
||||
formatted: FormattedBody? = null,
|
||||
itemTypes: List<GalleryItemType> = listOf(aRustGalleryItemTypeImage()),
|
||||
) = GalleryMessageContent(body = body, formatted = formatted, itemtypes = itemTypes)
|
||||
|
||||
internal fun aRustGalleryItemTypeImage(
|
||||
content: ImageMessageContent = aRustImageMessageContent(),
|
||||
) = GalleryItemType.Image(content = content)
|
||||
|
||||
internal fun aRustGalleryItemTypeAudio(
|
||||
content: AudioMessageContent = aRustAudioMessageContent(),
|
||||
) = GalleryItemType.Audio(content = content)
|
||||
|
||||
internal fun aRustGalleryItemTypeVideo(
|
||||
content: VideoMessageContent = aRustVideoMessageContent(),
|
||||
) = GalleryItemType.Video(content = content)
|
||||
|
||||
internal fun aRustGalleryItemTypeFile(
|
||||
content: FileMessageContent = aRustFileMessageContent(),
|
||||
) = GalleryItemType.File(content = content)
|
||||
|
||||
internal fun aRustGalleryItemTypeOther(
|
||||
itemType: String = "m.unknown",
|
||||
body: String = "unknown item",
|
||||
) = GalleryItemType.Other(itemtype = itemType, body = body)
|
||||
|
||||
internal fun aRustImageMessageContent(
|
||||
filename: String = "image.jpg",
|
||||
caption: String? = null,
|
||||
formattedCaption: FormattedBody? = null,
|
||||
source: MediaSource = FakeFfiMediaSource("mxc://server/image"),
|
||||
info: ImageInfo? = null,
|
||||
) = ImageMessageContent(
|
||||
filename = filename,
|
||||
caption = caption,
|
||||
formattedCaption = formattedCaption,
|
||||
source = source,
|
||||
info = info,
|
||||
)
|
||||
|
||||
internal fun aRustAudioMessageContent(
|
||||
filename: String = "audio.mp3",
|
||||
caption: String? = null,
|
||||
formattedCaption: FormattedBody? = null,
|
||||
source: MediaSource = FakeFfiMediaSource("mxc://server/audio"),
|
||||
info: AudioInfo? = null,
|
||||
audio: UnstableAudioDetailsContent? = null,
|
||||
voice: UnstableVoiceContent? = null,
|
||||
) = AudioMessageContent(
|
||||
filename = filename,
|
||||
caption = caption,
|
||||
formattedCaption = formattedCaption,
|
||||
source = source,
|
||||
info = info,
|
||||
audio = audio,
|
||||
voice = voice,
|
||||
)
|
||||
|
||||
internal fun aRustVideoMessageContent(
|
||||
filename: String = "video.mp4",
|
||||
caption: String? = null,
|
||||
formattedCaption: FormattedBody? = null,
|
||||
source: MediaSource = FakeFfiMediaSource("mxc://server/video"),
|
||||
info: VideoInfo? = null,
|
||||
) = VideoMessageContent(
|
||||
filename = filename,
|
||||
caption = caption,
|
||||
formattedCaption = formattedCaption,
|
||||
source = source,
|
||||
info = info,
|
||||
)
|
||||
|
||||
internal fun aRustFileMessageContent(
|
||||
filename: String = "document.pdf",
|
||||
caption: String? = null,
|
||||
formattedCaption: FormattedBody? = null,
|
||||
source: MediaSource = FakeFfiMediaSource("mxc://server/file"),
|
||||
info: FileInfo? = null,
|
||||
) = FileMessageContent(
|
||||
filename = filename,
|
||||
caption = caption,
|
||||
formattedCaption = formattedCaption,
|
||||
source = source,
|
||||
info = info,
|
||||
)
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.matrix.impl.fixtures.fakes
|
||||
|
||||
import org.matrix.rustcomponents.sdk.MediaSource
|
||||
import org.matrix.rustcomponents.sdk.NoHandle
|
||||
|
||||
internal class FakeFfiMediaSource(
|
||||
private val fakeUrl: String = "mxc://server/media",
|
||||
) : MediaSource(NoHandle) {
|
||||
override fun url(): String = fakeUrl
|
||||
override fun toJson(): String = """{"url":"$fakeUrl"}"""
|
||||
}
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.matrix.impl.timeline.item.event
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryItemType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
|
||||
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustAudioMessageContent
|
||||
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustFileMessageContent
|
||||
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustGalleryItemTypeAudio
|
||||
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustGalleryItemTypeFile
|
||||
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustGalleryItemTypeImage
|
||||
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustGalleryItemTypeOther
|
||||
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustGalleryItemTypeVideo
|
||||
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustGalleryMessageContent
|
||||
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustImageMessageContent
|
||||
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustMessageTypeGallery
|
||||
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustVideoMessageContent
|
||||
import org.junit.Test
|
||||
import org.matrix.rustcomponents.sdk.EmoteMessageContent
|
||||
import org.matrix.rustcomponents.sdk.LocationContent
|
||||
import org.matrix.rustcomponents.sdk.MessageType
|
||||
import org.matrix.rustcomponents.sdk.NoticeMessageContent
|
||||
import org.matrix.rustcomponents.sdk.TextMessageContent
|
||||
import org.matrix.rustcomponents.sdk.UnstableVoiceContent
|
||||
import org.matrix.rustcomponents.sdk.AssetType as RustAssetType
|
||||
|
||||
class EventMessageMapperTest {
|
||||
private val sut = EventMessageMapper()
|
||||
|
||||
@Test
|
||||
fun `mapMessageType with Text returns TextMessageType`() {
|
||||
val result = sut.mapMessageType(
|
||||
MessageType.Text(
|
||||
content = TextMessageContent(body = "Hello", formatted = null)
|
||||
)
|
||||
)
|
||||
assertThat(result).isEqualTo(TextMessageType(body = "Hello", formatted = null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mapMessageType with Notice returns NoticeMessageType`() {
|
||||
val result = sut.mapMessageType(
|
||||
MessageType.Notice(content = NoticeMessageContent(body = "A notice", formatted = null))
|
||||
)
|
||||
assertThat(result).isEqualTo(NoticeMessageType(body = "A notice", formatted = null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mapMessageType with Emote returns EmoteMessageType`() {
|
||||
val result = sut.mapMessageType(
|
||||
MessageType.Emote(content = EmoteMessageContent(body = "An emote", formatted = null))
|
||||
)
|
||||
assertThat(result).isEqualTo(EmoteMessageType(body = "An emote", formatted = null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mapMessageType with Other returns OtherMessageType`() {
|
||||
val result = sut.mapMessageType(
|
||||
MessageType.Other(msgtype = "m.custom", body = "custom body")
|
||||
)
|
||||
assertThat(result).isEqualTo(OtherMessageType(msgType = "m.custom", body = "custom body"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mapMessageType with Location returns LocationMessageType`() {
|
||||
val result = sut.mapMessageType(
|
||||
MessageType.Location(
|
||||
content = LocationContent(
|
||||
body = "Location body",
|
||||
geoUri = "geo:51.5,-0.1",
|
||||
description = "London",
|
||||
zoomLevel = null,
|
||||
asset = RustAssetType.PIN,
|
||||
)
|
||||
)
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
LocationMessageType(
|
||||
body = "Location body",
|
||||
geoUri = "geo:51.5,-0.1",
|
||||
description = "London",
|
||||
assetType = AssetType.PIN,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mapMessageType with Audio without voice returns AudioMessageType`() {
|
||||
val result = sut.mapMessageType(
|
||||
MessageType.Audio(content = aRustAudioMessageContent(filename = "audio.mp3", voice = null))
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
AudioMessageType(
|
||||
filename = "audio.mp3",
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
source = MediaSource(url = "mxc://server/audio", json = """{"url":"mxc://server/audio"}"""),
|
||||
info = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mapMessageType with Audio with voice returns VoiceMessageType`() {
|
||||
val result = sut.mapMessageType(
|
||||
MessageType.Audio(content = aRustAudioMessageContent(filename = "voice.ogg", voice = UnstableVoiceContent()))
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
VoiceMessageType(
|
||||
filename = "voice.ogg",
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
source = MediaSource(url = "mxc://server/audio", json = """{"url":"mxc://server/audio"}"""),
|
||||
info = null,
|
||||
details = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mapMessageType with File returns FileMessageType`() {
|
||||
val result = sut.mapMessageType(
|
||||
MessageType.File(content = aRustFileMessageContent(filename = "document.pdf"))
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
FileMessageType(
|
||||
filename = "document.pdf",
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
source = MediaSource(url = "mxc://server/file", json = """{"url":"mxc://server/file"}"""),
|
||||
info = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mapMessageType with Image returns ImageMessageType`() {
|
||||
val result = sut.mapMessageType(
|
||||
MessageType.Image(content = aRustImageMessageContent(filename = "image.jpg"))
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
ImageMessageType(
|
||||
filename = "image.jpg",
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
source = MediaSource(url = "mxc://server/image", json = """{"url":"mxc://server/image"}"""),
|
||||
info = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mapMessageType with Video returns VideoMessageType`() {
|
||||
val result = sut.mapMessageType(
|
||||
MessageType.Video(content = aRustVideoMessageContent(filename = "video.mp4"))
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
VideoMessageType(
|
||||
filename = "video.mp4",
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
source = MediaSource(url = "mxc://server/video", json = """{"url":"mxc://server/video"}"""),
|
||||
info = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mapMessageType with Gallery with Image item returns GalleryMessageType with Image`() {
|
||||
val result = sut.mapMessageType(
|
||||
aRustMessageTypeGallery(
|
||||
content = aRustGalleryMessageContent(
|
||||
body = "A gallery",
|
||||
itemTypes = listOf(aRustGalleryItemTypeImage(aRustImageMessageContent(filename = "image.jpg"))),
|
||||
)
|
||||
)
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
GalleryMessageType(
|
||||
body = "A gallery",
|
||||
formatted = null,
|
||||
items = listOf(
|
||||
GalleryItemType.Image(
|
||||
content = ImageMessageType(
|
||||
filename = "image.jpg",
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
source = MediaSource(url = "mxc://server/image", json = """{"url":"mxc://server/image"}"""),
|
||||
info = null,
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mapMessageType with Gallery with Audio item returns GalleryMessageType with Audio`() {
|
||||
val result = sut.mapMessageType(
|
||||
aRustMessageTypeGallery(
|
||||
content = aRustGalleryMessageContent(
|
||||
itemTypes = listOf(aRustGalleryItemTypeAudio(aRustAudioMessageContent(filename = "audio.mp3")))
|
||||
)
|
||||
)
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
GalleryMessageType(
|
||||
body = "A gallery",
|
||||
formatted = null,
|
||||
items = listOf(
|
||||
GalleryItemType.Audio(
|
||||
content = AudioMessageType(
|
||||
filename = "audio.mp3",
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
source = MediaSource(url = "mxc://server/audio", json = """{"url":"mxc://server/audio"}"""),
|
||||
info = null,
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mapMessageType with Gallery with Video item returns GalleryMessageType with Video`() {
|
||||
val result = sut.mapMessageType(
|
||||
aRustMessageTypeGallery(
|
||||
content = aRustGalleryMessageContent(
|
||||
itemTypes = listOf(aRustGalleryItemTypeVideo(aRustVideoMessageContent(filename = "video.mp4")))
|
||||
)
|
||||
)
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
GalleryMessageType(
|
||||
body = "A gallery",
|
||||
formatted = null,
|
||||
items = listOf(
|
||||
GalleryItemType.Video(
|
||||
content = VideoMessageType(
|
||||
filename = "video.mp4",
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
source = MediaSource(url = "mxc://server/video", json = """{"url":"mxc://server/video"}"""),
|
||||
info = null,
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mapMessageType with Gallery with File item returns GalleryMessageType with File`() {
|
||||
val result = sut.mapMessageType(
|
||||
aRustMessageTypeGallery(
|
||||
content = aRustGalleryMessageContent(
|
||||
itemTypes = listOf(aRustGalleryItemTypeFile(aRustFileMessageContent(filename = "document.pdf")))
|
||||
)
|
||||
)
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
GalleryMessageType(
|
||||
body = "A gallery",
|
||||
formatted = null,
|
||||
items = listOf(
|
||||
GalleryItemType.File(
|
||||
content = FileMessageType(
|
||||
filename = "document.pdf",
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
source = MediaSource(url = "mxc://server/file", json = """{"url":"mxc://server/file"}"""),
|
||||
info = null,
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mapMessageType with Gallery with Other item returns GalleryMessageType with Other`() {
|
||||
val result = sut.mapMessageType(
|
||||
aRustMessageTypeGallery(
|
||||
content = aRustGalleryMessageContent(
|
||||
itemTypes = listOf(aRustGalleryItemTypeOther(itemType = "m.custom", body = "custom item"))
|
||||
)
|
||||
)
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
GalleryMessageType(
|
||||
body = "A gallery",
|
||||
formatted = null,
|
||||
items = listOf(
|
||||
GalleryItemType.Other(itemType = "m.custom", body = "custom item")
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
+24
@@ -13,6 +13,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.TransactionId
|
||||
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||
import io.element.android.libraries.matrix.api.media.GalleryItemInfo
|
||||
import io.element.android.libraries.matrix.api.media.ImageInfo
|
||||
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
|
||||
import io.element.android.libraries.matrix.api.media.VideoInfo
|
||||
@@ -293,6 +294,29 @@ class FakeTimeline(
|
||||
)
|
||||
}
|
||||
|
||||
var sendGalleryLambda: (
|
||||
items: List<GalleryItemInfo>,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
inReplyToEventId: EventId?,
|
||||
) -> Result<MediaUploadHandler> = { _, _, _, _ ->
|
||||
Result.success(FakeMediaUploadHandler())
|
||||
}
|
||||
|
||||
override suspend fun sendGallery(
|
||||
items: List<GalleryItemInfo>,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
inReplyToEventId: EventId?,
|
||||
): Result<MediaUploadHandler> = simulateLongTask {
|
||||
sendGalleryLambda(
|
||||
items,
|
||||
caption,
|
||||
formattedCaption,
|
||||
inReplyToEventId,
|
||||
)
|
||||
}
|
||||
|
||||
var sendLocationLambda: (
|
||||
body: String,
|
||||
geoUri: String,
|
||||
|
||||
+11
@@ -23,12 +23,23 @@ interface PickerProvider {
|
||||
onResult: (Uri?) -> Unit
|
||||
): PickerLauncher<PickVisualMediaRequest, Uri?>
|
||||
|
||||
@Composable
|
||||
fun registerGalleryMultiPicker(
|
||||
onResult: (uris: List<Uri>) -> Unit
|
||||
): PickerLauncher<PickVisualMediaRequest, List<Uri>>
|
||||
|
||||
@Composable
|
||||
fun registerFilePicker(
|
||||
mimeType: String,
|
||||
onResult: (uri: Uri?, mimeType: String?) -> Unit,
|
||||
): PickerLauncher<String, Uri?>
|
||||
|
||||
@Composable
|
||||
fun registerFileMultiPicker(
|
||||
mimeType: String,
|
||||
onResult: (uris: List<Uri>) -> Unit,
|
||||
): PickerLauncher<Array<String>, List<Uri>>
|
||||
|
||||
@Composable
|
||||
fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean>
|
||||
|
||||
|
||||
+19
@@ -15,6 +15,9 @@ import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
|
||||
// As per MSC4274, the recommended item cap is 60.
|
||||
private const val MAX_GALLERY_ITEMS = 60
|
||||
|
||||
@Immutable
|
||||
sealed interface PickerType<Input, Output> {
|
||||
fun getContract(): ActivityResultContract<Input, Output>
|
||||
@@ -34,6 +37,13 @@ sealed interface PickerType<Input, Output> {
|
||||
}
|
||||
}
|
||||
|
||||
data object ImageAndVideoMulti : PickerType<PickVisualMediaRequest, List<Uri>> {
|
||||
override fun getContract() = ActivityResultContracts.PickMultipleVisualMedia(MAX_GALLERY_ITEMS)
|
||||
override fun getDefaultRequest(): PickVisualMediaRequest {
|
||||
return PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)
|
||||
}
|
||||
}
|
||||
|
||||
object Camera {
|
||||
data class Photo(val destUri: Uri) : PickerType<Uri, Boolean> {
|
||||
override fun getContract() = ActivityResultContracts.TakePicture()
|
||||
@@ -56,4 +66,13 @@ sealed interface PickerType<Input, Output> {
|
||||
return mimeType
|
||||
}
|
||||
}
|
||||
|
||||
data class FileMulti(val mimeType: String = MimeTypes.Any) : PickerType<Array<String>, List<Uri>> {
|
||||
override fun getContract(): ActivityResultContract<Array<String>, List<Uri>> {
|
||||
return ActivityResultContracts.OpenMultipleDocuments()
|
||||
}
|
||||
override fun getDefaultRequest(): Array<String> {
|
||||
return arrayOf(mimeType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+35
-2
@@ -80,6 +80,23 @@ class DefaultPickerProvider(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers and returns a [PickerLauncher] for selecting multiple gallery items (images/videos).
|
||||
* [onResult] will be called with the list of selected file [Uri]s.
|
||||
*/
|
||||
@Composable
|
||||
override fun registerGalleryMultiPicker(
|
||||
onResult: (uris: List<Uri>) -> Unit
|
||||
): PickerLauncher<PickVisualMediaRequest, List<Uri>> {
|
||||
return if (LocalInspectionMode.current) {
|
||||
NoOpPickerLauncher { onResult(emptyList()) }
|
||||
} else {
|
||||
rememberPickerLauncher(type = PickerType.ImageAndVideoMulti) { uris ->
|
||||
onResult(uris)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers and returns a [PickerLauncher] for a file of a certain [mimeType] (any type of file, by default).
|
||||
* [onResult] will be called with either the selected file's [Uri] or `null` if nothing was selected.
|
||||
@@ -100,6 +117,24 @@ class DefaultPickerProvider(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers and returns a [PickerLauncher] for selecting multiple files of a certain [mimeType].
|
||||
* [onResult] will be called with the list of selected file URIs.
|
||||
*/
|
||||
@Composable
|
||||
override fun registerFileMultiPicker(
|
||||
mimeType: String,
|
||||
onResult: (uris: List<Uri>) -> Unit,
|
||||
): PickerLauncher<Array<String>, List<Uri>> {
|
||||
return if (LocalInspectionMode.current) {
|
||||
NoOpPickerLauncher { onResult(emptyList()) }
|
||||
} else {
|
||||
rememberPickerLauncher(type = PickerType.FileMulti(mimeType)) { uris ->
|
||||
onResult(uris)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers and returns a [PickerLauncher] for taking a photo with a camera app.
|
||||
* @param [onResult] will be called with either the photo's [Uri] or `null` if nothing was selected.
|
||||
@@ -113,7 +148,6 @@ class DefaultPickerProvider(
|
||||
val tmpFile = remember { getTemporaryFile("photo.jpg") }
|
||||
val tmpFileUri = remember(tmpFile) { getTemporaryUri(tmpFile) }
|
||||
rememberPickerLauncher(type = PickerType.Camera.Photo(tmpFileUri)) { success ->
|
||||
// Execute callback
|
||||
onResult(if (success) tmpFileUri else null)
|
||||
}
|
||||
}
|
||||
@@ -125,7 +159,6 @@ class DefaultPickerProvider(
|
||||
*/
|
||||
@Composable
|
||||
override fun registerCameraVideoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean> {
|
||||
// Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker
|
||||
return if (LocalInspectionMode.current) {
|
||||
NoOpPickerLauncher { onResult(null) }
|
||||
} else {
|
||||
|
||||
+10
@@ -30,11 +30,21 @@ class FakePickerProvider : PickerProvider {
|
||||
return NoOpPickerLauncher { onResult(result) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun registerGalleryMultiPicker(onResult: (uris: List<Uri>) -> Unit): PickerLauncher<PickVisualMediaRequest, List<Uri>> {
|
||||
return NoOpPickerLauncher { onResult(result?.let { listOf(it) } ?: emptyList()) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun registerFilePicker(mimeType: String, onResult: (Uri?, String?) -> Unit): PickerLauncher<String, Uri?> {
|
||||
return NoOpPickerLauncher { onResult(result, this.mimeType) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun registerFileMultiPicker(mimeType: String, onResult: (uris: List<Uri>) -> Unit): PickerLauncher<Array<String>, List<Uri>> {
|
||||
return NoOpPickerLauncher { onResult(result?.let { listOf(it) } ?: emptyList()) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean> {
|
||||
return NoOpPickerLauncher { onResult(result) }
|
||||
|
||||
+7
@@ -61,5 +61,12 @@ interface MediaSender {
|
||||
inReplyToEventId: EventId? = null,
|
||||
): Result<Unit>
|
||||
|
||||
suspend fun sendGallery(
|
||||
mediaUploadInfos: List<MediaUploadInfo>,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
inReplyToEventId: EventId?,
|
||||
): Result<Unit>
|
||||
|
||||
fun cleanUp()
|
||||
}
|
||||
|
||||
+28
@@ -10,6 +10,7 @@ package io.element.android.libraries.mediaupload.api
|
||||
|
||||
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||
import io.element.android.libraries.matrix.api.media.GalleryItemInfo
|
||||
import io.element.android.libraries.matrix.api.media.ImageInfo
|
||||
import io.element.android.libraries.matrix.api.media.VideoInfo
|
||||
import java.io.File
|
||||
@@ -31,3 +32,30 @@ fun MediaUploadInfo.allFiles(): List<File> {
|
||||
(this@allFiles as? MediaUploadInfo.Video)?.thumbnailFile,
|
||||
)
|
||||
}
|
||||
|
||||
fun MediaUploadInfo.toGalleryItemInfo(): GalleryItemInfo {
|
||||
return when (this) {
|
||||
is MediaUploadInfo.Image -> GalleryItemInfo.Image(
|
||||
file = file,
|
||||
imageInfo = imageInfo,
|
||||
thumbnailFile = thumbnailFile,
|
||||
)
|
||||
is MediaUploadInfo.Video -> GalleryItemInfo.Video(
|
||||
file = file,
|
||||
videoInfo = videoInfo,
|
||||
thumbnailFile = thumbnailFile,
|
||||
)
|
||||
is MediaUploadInfo.Audio -> GalleryItemInfo.Audio(
|
||||
file = file,
|
||||
audioInfo = audioInfo,
|
||||
)
|
||||
is MediaUploadInfo.VoiceMessage -> GalleryItemInfo.Audio(
|
||||
file = file,
|
||||
audioInfo = audioInfo,
|
||||
)
|
||||
is MediaUploadInfo.AnyFile -> GalleryItemInfo.MediaFile(
|
||||
file = file,
|
||||
fileInfo = fileInfo,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+24
@@ -27,6 +27,7 @@ import io.element.android.libraries.mediaupload.api.MediaSender
|
||||
import io.element.android.libraries.mediaupload.api.MediaSenderFactory
|
||||
import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory
|
||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
||||
import io.element.android.libraries.mediaupload.api.toGalleryItemInfo
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import timber.log.Timber
|
||||
@@ -167,6 +168,29 @@ class DefaultMediaSender(
|
||||
.handleSendResult(mediaId(uri))
|
||||
}
|
||||
|
||||
override suspend fun sendGallery(
|
||||
mediaUploadInfos: List<MediaUploadInfo>,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
inReplyToEventId: EventId?,
|
||||
): Result<Unit> {
|
||||
val galleryLogId = "gallery[${mediaUploadInfos.size} items]"
|
||||
Timber.d("Sending $galleryLogId")
|
||||
return getTimeline().flatMap { timeline ->
|
||||
val galleryItems = mediaUploadInfos.map { it.toGalleryItemInfo() }
|
||||
timeline.sendGallery(
|
||||
items = galleryItems,
|
||||
caption = caption,
|
||||
formattedCaption = formattedCaption,
|
||||
inReplyToEventId = inReplyToEventId,
|
||||
)
|
||||
}
|
||||
.flatMapCatching { uploadHandler ->
|
||||
uploadHandler.await()
|
||||
}
|
||||
.handleSendResult(galleryLogId)
|
||||
}
|
||||
|
||||
private fun Result<Unit>.handleSendResult(mediaId: String) = this
|
||||
.onFailure { error ->
|
||||
val job = ongoingUploadJobs.remove(Job)
|
||||
|
||||
+10
@@ -19,6 +19,7 @@ class FakeMediaSender(
|
||||
private val sendPreProcessedMediaResult: () -> Result<Unit> = { lambdaError() },
|
||||
private val sendMediaResult: () -> Result<Unit> = { lambdaError() },
|
||||
private val sendVoiceMessageResult: () -> Result<Unit> = { lambdaError() },
|
||||
private val sendGalleryResult: () -> Result<Unit> = { lambdaError() },
|
||||
private val cleanUpResult: () -> Unit = { lambdaError() },
|
||||
) : MediaSender {
|
||||
override suspend fun preProcessMedia(
|
||||
@@ -58,6 +59,15 @@ class FakeMediaSender(
|
||||
return sendVoiceMessageResult()
|
||||
}
|
||||
|
||||
override suspend fun sendGallery(
|
||||
mediaUploadInfos: List<MediaUploadInfo>,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
inReplyToEventId: EventId?,
|
||||
): Result<Unit> {
|
||||
return sendGalleryResult()
|
||||
}
|
||||
|
||||
override fun cleanUp() {
|
||||
cleanUpResult()
|
||||
}
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.api
|
||||
|
||||
import android.os.Parcelable
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class GalleryItemData(
|
||||
val filename: String,
|
||||
val mimeType: String,
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val type: Type,
|
||||
) : Parcelable {
|
||||
enum class Type {
|
||||
Image,
|
||||
Video,
|
||||
Audio,
|
||||
File,
|
||||
}
|
||||
}
|
||||
+17
@@ -13,6 +13,23 @@ import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class GalleryInfo(
|
||||
val caption: String?,
|
||||
val formattedCaption: CharSequence? = null,
|
||||
val senderId: UserId?,
|
||||
val senderName: String?,
|
||||
val senderAvatar: String?,
|
||||
val dateSent: String?,
|
||||
val dateSentFull: String?,
|
||||
val initialIndex: Int,
|
||||
) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
data class AvatarInfo(
|
||||
val filename: String,
|
||||
) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
data class MediaInfo(
|
||||
val filename: String,
|
||||
|
||||
+23
-9
@@ -35,18 +35,32 @@ interface MediaViewerEntryPoint : FeatureEntryPoint {
|
||||
fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean)
|
||||
}
|
||||
|
||||
data class Params(
|
||||
val mode: MediaViewerMode,
|
||||
val eventId: EventId?,
|
||||
val mediaInfo: MediaInfo,
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val canShowInfo: Boolean,
|
||||
) : NodeInputs
|
||||
sealed interface Params : NodeInputs {
|
||||
data class RoomMedia(
|
||||
val mode: MediaViewerMode,
|
||||
val eventId: EventId?,
|
||||
val mediaInfo: MediaInfo,
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
) : Params
|
||||
|
||||
data class EventGallery(
|
||||
val eventId: EventId?,
|
||||
val galleryInfo: GalleryInfo,
|
||||
val galleryItems: List<GalleryItemData>,
|
||||
val fromPinnedMessages: Boolean,
|
||||
) : Params
|
||||
|
||||
data class Avatar(
|
||||
val avatarInfo: AvatarInfo,
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
) : Params
|
||||
}
|
||||
|
||||
sealed interface MediaViewerMode : Parcelable {
|
||||
@Parcelize
|
||||
data object SingleMedia : MediaViewerMode
|
||||
data class EventGallery(val fromPinnedMessages: Boolean) : MediaViewerMode
|
||||
|
||||
@Parcelize
|
||||
data class TimelineImagesAndVideos(val timelineMode: Timeline.Mode) : MediaViewerMode
|
||||
|
||||
+3
-23
@@ -13,40 +13,20 @@ import com.bumble.appyx.core.node.Node
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
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.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.AvatarInfo
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||
import io.element.android.libraries.mediaviewer.impl.viewer.MediaViewerNode
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultMediaViewerEntryPoint : MediaViewerEntryPoint {
|
||||
override fun createParamsForAvatar(filename: String, avatarUrl: String): MediaViewerEntryPoint.Params {
|
||||
// We need to fake the MimeType here for the viewer to work.
|
||||
val mimeType = MimeTypes.Images
|
||||
return MediaViewerEntryPoint.Params(
|
||||
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
|
||||
eventId = null,
|
||||
mediaInfo = MediaInfo(
|
||||
return MediaViewerEntryPoint.Params.Avatar(
|
||||
avatarInfo = AvatarInfo(
|
||||
filename = filename,
|
||||
fileSize = null,
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
mimeType = mimeType,
|
||||
formattedFileSize = "",
|
||||
fileExtension = "",
|
||||
senderId = UserId("@dummy:server.org"),
|
||||
senderName = null,
|
||||
senderAvatar = null,
|
||||
dateSent = null,
|
||||
dateSentFull = null,
|
||||
waveform = null,
|
||||
duration = null,
|
||||
),
|
||||
mediaSource = MediaSource(url = avatarUrl),
|
||||
thumbnailSource = null,
|
||||
canShowInfo = false,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+129
-15
@@ -13,6 +13,7 @@ import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
||||
import io.element.android.libraries.dateformatter.api.toHumanReadableDuration
|
||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent
|
||||
@@ -20,6 +21,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageT
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryItemType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent
|
||||
@@ -54,7 +57,7 @@ class EventItemFactory(
|
||||
) {
|
||||
fun create(
|
||||
currentTimelineItem: MatrixTimelineItem.Event,
|
||||
): MediaItem.Event? {
|
||||
): List<MediaItem.Event> {
|
||||
val event = currentTimelineItem.event
|
||||
val dateSent = dateFormatter.format(
|
||||
currentTimelineItem.event.timestamp,
|
||||
@@ -79,7 +82,7 @@ class EventItemFactory(
|
||||
is LiveLocationContent,
|
||||
UnknownContent -> {
|
||||
Timber.w("Should not happen: ${content.javaClass.simpleName}")
|
||||
null
|
||||
emptyList()
|
||||
}
|
||||
is MessageContent -> {
|
||||
when (val type = content.type) {
|
||||
@@ -89,9 +92,93 @@ class EventItemFactory(
|
||||
is LocationMessageType,
|
||||
is TextMessageType -> {
|
||||
Timber.w("Should not happen: ${content.type}")
|
||||
null
|
||||
emptyList()
|
||||
}
|
||||
is AudioMessageType -> MediaItem.Audio(
|
||||
is GalleryMessageType -> {
|
||||
val baseId = currentTimelineItem.uniqueId.value
|
||||
type.items.mapIndexedNotNull { index, galleryItem ->
|
||||
val id = UniqueId("${baseId}_$index")
|
||||
when (galleryItem) {
|
||||
is GalleryItemType.Image -> {
|
||||
val c = galleryItem.content
|
||||
MediaItem.Image(
|
||||
id = id,
|
||||
eventId = currentTimelineItem.eventId,
|
||||
mediaInfo = createMediaInfo(
|
||||
filename = c.filename,
|
||||
fileSize = c.info?.size,
|
||||
caption = c.caption,
|
||||
mimeType = c.info?.mimetype.orEmpty(),
|
||||
fileExtension = c.filename,
|
||||
event = event,
|
||||
dateSent = dateSent,
|
||||
dateSentFull = dateSentFull,
|
||||
),
|
||||
mediaSource = c.source,
|
||||
thumbnailSource = c.info?.thumbnailSource,
|
||||
)
|
||||
}
|
||||
is GalleryItemType.Video -> {
|
||||
val c = galleryItem.content
|
||||
MediaItem.Video(
|
||||
id = id,
|
||||
eventId = currentTimelineItem.eventId,
|
||||
mediaInfo = createMediaInfo(
|
||||
filename = c.filename,
|
||||
fileSize = c.info?.size,
|
||||
caption = c.caption,
|
||||
mimeType = c.info?.mimetype.orEmpty(),
|
||||
fileExtension = c.filename,
|
||||
event = event,
|
||||
dateSent = dateSent,
|
||||
dateSentFull = dateSentFull,
|
||||
duration = c.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
|
||||
),
|
||||
mediaSource = c.source,
|
||||
thumbnailSource = c.info?.thumbnailSource,
|
||||
)
|
||||
}
|
||||
is GalleryItemType.Audio -> {
|
||||
val c = galleryItem.content
|
||||
MediaItem.Audio(
|
||||
id = id,
|
||||
eventId = currentTimelineItem.eventId,
|
||||
mediaInfo = createMediaInfo(
|
||||
filename = c.filename,
|
||||
fileSize = c.info?.size,
|
||||
caption = c.caption,
|
||||
mimeType = c.info?.mimetype.orEmpty(),
|
||||
fileExtension = c.filename,
|
||||
event = event,
|
||||
dateSent = dateSent,
|
||||
dateSentFull = dateSentFull,
|
||||
),
|
||||
mediaSource = c.source,
|
||||
)
|
||||
}
|
||||
is GalleryItemType.File -> {
|
||||
val c = galleryItem.content
|
||||
MediaItem.File(
|
||||
id = id,
|
||||
eventId = currentTimelineItem.eventId,
|
||||
mediaInfo = createMediaInfo(
|
||||
filename = c.filename,
|
||||
fileSize = c.info?.size,
|
||||
caption = c.caption,
|
||||
mimeType = c.info?.mimetype.orEmpty(),
|
||||
fileExtension = c.filename,
|
||||
event = event,
|
||||
dateSent = dateSent,
|
||||
dateSentFull = dateSentFull,
|
||||
),
|
||||
mediaSource = c.source,
|
||||
)
|
||||
}
|
||||
is GalleryItemType.Other -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
is AudioMessageType -> listOf(MediaItem.Audio(
|
||||
id = currentTimelineItem.uniqueId,
|
||||
eventId = currentTimelineItem.eventId,
|
||||
mediaInfo = MediaInfo(
|
||||
@@ -111,8 +198,8 @@ class EventItemFactory(
|
||||
duration = null,
|
||||
),
|
||||
mediaSource = type.source,
|
||||
)
|
||||
is FileMessageType -> MediaItem.File(
|
||||
))
|
||||
is FileMessageType -> listOf(MediaItem.File(
|
||||
id = currentTimelineItem.uniqueId,
|
||||
eventId = currentTimelineItem.eventId,
|
||||
mediaInfo = MediaInfo(
|
||||
@@ -133,8 +220,8 @@ class EventItemFactory(
|
||||
),
|
||||
mediaSource = type.source,
|
||||
// TODO We may want to add a thumbnailSource and set it to type.info?.thumbnailSource
|
||||
)
|
||||
is ImageMessageType -> MediaItem.Image(
|
||||
))
|
||||
is ImageMessageType -> listOf(MediaItem.Image(
|
||||
id = currentTimelineItem.uniqueId,
|
||||
eventId = currentTimelineItem.eventId,
|
||||
mediaInfo = MediaInfo(
|
||||
@@ -155,8 +242,8 @@ class EventItemFactory(
|
||||
),
|
||||
mediaSource = type.source,
|
||||
thumbnailSource = type.info?.thumbnailSource,
|
||||
)
|
||||
is StickerMessageType -> MediaItem.Image(
|
||||
))
|
||||
is StickerMessageType -> listOf(MediaItem.Image(
|
||||
id = currentTimelineItem.uniqueId,
|
||||
eventId = currentTimelineItem.eventId,
|
||||
mediaInfo = MediaInfo(
|
||||
@@ -177,8 +264,8 @@ class EventItemFactory(
|
||||
),
|
||||
mediaSource = type.source,
|
||||
thumbnailSource = type.info?.thumbnailSource,
|
||||
)
|
||||
is VideoMessageType -> MediaItem.Video(
|
||||
))
|
||||
is VideoMessageType -> listOf(MediaItem.Video(
|
||||
id = currentTimelineItem.uniqueId,
|
||||
eventId = currentTimelineItem.eventId,
|
||||
mediaInfo = MediaInfo(
|
||||
@@ -199,8 +286,8 @@ class EventItemFactory(
|
||||
),
|
||||
mediaSource = type.source,
|
||||
thumbnailSource = type.info?.thumbnailSource,
|
||||
)
|
||||
is VoiceMessageType -> MediaItem.Voice(
|
||||
))
|
||||
is VoiceMessageType -> listOf(MediaItem.Voice(
|
||||
id = currentTimelineItem.uniqueId,
|
||||
eventId = currentTimelineItem.eventId,
|
||||
mediaInfo = MediaInfo(
|
||||
@@ -220,9 +307,36 @@ class EventItemFactory(
|
||||
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
|
||||
),
|
||||
mediaSource = type.source,
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createMediaInfo(
|
||||
filename: String,
|
||||
fileSize: Long?,
|
||||
caption: String?,
|
||||
mimeType: String,
|
||||
fileExtension: String,
|
||||
event: io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem,
|
||||
dateSent: String,
|
||||
dateSentFull: String,
|
||||
waveform: List<Float>? = null,
|
||||
duration: String? = null,
|
||||
) = MediaInfo(
|
||||
filename = filename,
|
||||
fileSize = fileSize,
|
||||
caption = caption,
|
||||
mimeType = mimeType,
|
||||
formattedFileSize = fileSize?.let { fileSizeFormatter.format(it) }.orEmpty(),
|
||||
fileExtension = fileExtensionExtractor.extractFromName(fileExtension),
|
||||
senderId = event.sender,
|
||||
senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender),
|
||||
senderAvatar = event.senderProfile.getAvatarUrl(),
|
||||
dateSent = dateSent,
|
||||
dateSentFull = dateSentFull,
|
||||
waveform = waveform,
|
||||
duration = duration,
|
||||
)
|
||||
}
|
||||
|
||||
+36
-46
@@ -9,10 +9,8 @@
|
||||
package io.element.android.libraries.mediaviewer.impl.datasource
|
||||
|
||||
import dev.zacsweers.metro.Inject
|
||||
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.core.UniqueId
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -32,18 +30,8 @@ class TimelineMediaItemsFactory(
|
||||
) {
|
||||
private val _timelineItems = MutableSharedFlow<ImmutableList<MediaItem>>(replay = 1)
|
||||
private val lock = Mutex()
|
||||
private val diffCache = MutableListDiffCache<MediaItem>()
|
||||
private val diffCacheUpdater = DiffCacheUpdater<MatrixTimelineItem, MediaItem>(
|
||||
diffCache = diffCache,
|
||||
detectMoves = false,
|
||||
cacheInvalidator = DefaultDiffCacheInvalidator()
|
||||
) { old, new ->
|
||||
if (old is MatrixTimelineItem.Event && new is MatrixTimelineItem.Event) {
|
||||
old.uniqueId == new.uniqueId
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
private val cache = mutableMapOf<UniqueId, List<MediaItem>>()
|
||||
private var previousTimelineItems: List<MatrixTimelineItem> = emptyList()
|
||||
|
||||
val timelineItems: Flow<ImmutableList<MediaItem>> = _timelineItems.distinctUntilChanged()
|
||||
|
||||
@@ -51,39 +39,41 @@ class TimelineMediaItemsFactory(
|
||||
timelineItems: List<MatrixTimelineItem>,
|
||||
) = withContext(dispatchers.computation) {
|
||||
lock.withLock {
|
||||
diffCacheUpdater.updateWith(timelineItems)
|
||||
buildAndEmitTimelineItemStates(timelineItems)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun buildAndEmitTimelineItemStates(
|
||||
timelineItems: List<MatrixTimelineItem>,
|
||||
) {
|
||||
val newTimelineItemStates = ArrayList<MediaItem>()
|
||||
for (index in diffCache.indices().reversed()) {
|
||||
val cacheItem = diffCache.get(index)
|
||||
if (cacheItem == null) {
|
||||
buildAndCacheItem(timelineItems, index)?.also { timelineItemState ->
|
||||
newTimelineItemStates.add(timelineItemState)
|
||||
val newTimelineItemStates = ArrayList<MediaItem>()
|
||||
for (index in timelineItems.indices.reversed()) {
|
||||
when (val currentTimelineItem = timelineItems[index]) {
|
||||
is MatrixTimelineItem.Event -> {
|
||||
val cachedItems = cache[currentTimelineItem.uniqueId]
|
||||
val items = if (cachedItems != null && currentTimelineItem.isUnchanged(previousTimelineItems)) {
|
||||
cachedItems
|
||||
} else {
|
||||
eventItemFactory.create(currentTimelineItem).also { newItems ->
|
||||
cache[currentTimelineItem.uniqueId] = newItems
|
||||
}
|
||||
}
|
||||
newTimelineItemStates.addAll(items.asReversed())
|
||||
}
|
||||
is MatrixTimelineItem.Virtual -> {
|
||||
virtualItemFactory.create(currentTimelineItem)?.also {
|
||||
newTimelineItemStates.add(it)
|
||||
}
|
||||
}
|
||||
MatrixTimelineItem.Other -> Unit
|
||||
}
|
||||
} else {
|
||||
newTimelineItemStates.add(cacheItem)
|
||||
}
|
||||
previousTimelineItems = timelineItems
|
||||
_timelineItems.emit(newTimelineItemStates.toImmutableList())
|
||||
}
|
||||
_timelineItems.emit(newTimelineItemStates.toImmutableList())
|
||||
}
|
||||
|
||||
private fun buildAndCacheItem(
|
||||
timelineItems: List<MatrixTimelineItem>,
|
||||
index: Int,
|
||||
): MediaItem? {
|
||||
val timelineItem =
|
||||
when (val currentTimelineItem = timelineItems[index]) {
|
||||
is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem)
|
||||
is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem)
|
||||
MatrixTimelineItem.Other -> null
|
||||
}
|
||||
diffCache[index] = timelineItem
|
||||
return timelineItem
|
||||
}
|
||||
}
|
||||
|
||||
private fun MatrixTimelineItem.Event.isUnchanged(
|
||||
previousItems: List<MatrixTimelineItem>,
|
||||
): Boolean {
|
||||
val previousItem = previousItems
|
||||
.find { (it as? MatrixTimelineItem.Event)?.uniqueId == uniqueId }
|
||||
as? MatrixTimelineItem.Event
|
||||
return previousItem != null &&
|
||||
previousItem.event.eventId == event.eventId &&
|
||||
previousItem.event.timestamp == event.timestamp
|
||||
}
|
||||
|
||||
+1
-2
@@ -128,13 +128,12 @@ class MediaGalleryFlowNode(
|
||||
mediaViewerEntryPoint.createNode(
|
||||
parentNode = this,
|
||||
buildContext = buildContext,
|
||||
params = MediaViewerEntryPoint.Params(
|
||||
params = MediaViewerEntryPoint.Params.RoomMedia(
|
||||
mode = navTarget.mode,
|
||||
eventId = navTarget.eventId,
|
||||
mediaInfo = navTarget.mediaInfo,
|
||||
mediaSource = navTarget.mediaSource,
|
||||
thumbnailSource = navTarget.thumbnailSource,
|
||||
canShowInfo = true,
|
||||
),
|
||||
callback = callback,
|
||||
)
|
||||
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.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.GalleryInfo
|
||||
import io.element.android.libraries.mediaviewer.api.GalleryItemData
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
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.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
||||
class GalleryMediaGalleryDataSource(
|
||||
private val data: GroupedMediaItems,
|
||||
) : MediaGalleryDataSource {
|
||||
override val isReady: Boolean = true
|
||||
override fun start(coroutineScope: CoroutineScope) = 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(
|
||||
eventId: EventId?,
|
||||
galleryItems: List<GalleryItemData>,
|
||||
galleryInfo: GalleryInfo,
|
||||
): GalleryMediaGalleryDataSource {
|
||||
val mixedItems = mutableListOf<MediaItem.Event>()
|
||||
galleryItems.forEachIndexed { index, galleryItem ->
|
||||
val itemMediaInfo = MediaInfo(
|
||||
filename = galleryItem.filename,
|
||||
fileSize = null,
|
||||
caption = galleryInfo.caption,
|
||||
mimeType = galleryItem.mimeType,
|
||||
formattedFileSize = "",
|
||||
fileExtension = galleryItem.filename.substringAfterLast('.', ""),
|
||||
senderId = galleryInfo.senderId,
|
||||
senderName = galleryInfo.senderName,
|
||||
senderAvatar = galleryInfo.senderAvatar,
|
||||
dateSent = galleryInfo.dateSent,
|
||||
dateSentFull = galleryInfo.dateSentFull,
|
||||
waveform = null,
|
||||
duration = null,
|
||||
)
|
||||
val id = UniqueId("${eventId?.value ?: "gallery"}_$index")
|
||||
val mediaItem: MediaItem.Event = when (galleryItem.type) {
|
||||
GalleryItemData.Type.Video -> MediaItem.Video(
|
||||
id = id,
|
||||
eventId = eventId,
|
||||
mediaInfo = itemMediaInfo,
|
||||
mediaSource = galleryItem.mediaSource,
|
||||
thumbnailSource = galleryItem.thumbnailSource,
|
||||
)
|
||||
GalleryItemData.Type.Audio -> MediaItem.Audio(
|
||||
id = id,
|
||||
eventId = eventId,
|
||||
mediaInfo = itemMediaInfo,
|
||||
mediaSource = galleryItem.mediaSource,
|
||||
)
|
||||
GalleryItemData.Type.File -> MediaItem.File(
|
||||
id = id,
|
||||
eventId = eventId,
|
||||
mediaInfo = itemMediaInfo,
|
||||
mediaSource = galleryItem.mediaSource,
|
||||
)
|
||||
GalleryItemData.Type.Image -> MediaItem.Image(
|
||||
id = id,
|
||||
eventId = eventId,
|
||||
mediaInfo = itemMediaInfo,
|
||||
mediaSource = galleryItem.mediaSource,
|
||||
thumbnailSource = galleryItem.thumbnailSource,
|
||||
)
|
||||
}
|
||||
mixedItems.add(mediaItem)
|
||||
}
|
||||
return GalleryMediaGalleryDataSource(
|
||||
data = GroupedMediaItems(
|
||||
imageAndVideoItems = mixedItems.toImmutableList(),
|
||||
fileItems = persistentListOf(),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-3
@@ -62,9 +62,9 @@ class MediaViewerDataSource(
|
||||
private val mediaFiles: ConcurrentHashMap<MediaSource, MediaFile> = ConcurrentHashMap()
|
||||
|
||||
private val galleryMode = when (mode) {
|
||||
MediaViewerMode.SingleMedia,
|
||||
is MediaViewerMode.TimelineImagesAndVideos -> MediaGalleryMode.Images
|
||||
is MediaViewerMode.TimelineFilesAndAudios -> MediaGalleryMode.Files
|
||||
is MediaViewerMode.EventGallery -> MediaGalleryMode.Images
|
||||
}
|
||||
|
||||
// Map of sourceUrl to local media state
|
||||
@@ -98,9 +98,12 @@ class MediaViewerDataSource(
|
||||
/**
|
||||
* Find the index of the page corresponding to the given eventId, or null if not found.
|
||||
*/
|
||||
fun findEventIndex(eventId: EventId?): Int? {
|
||||
fun findEventIndex(eventId: EventId?, mediaSource: MediaSource? = null): Int? {
|
||||
if (eventId == null) return null
|
||||
return dataFlow.value.indexOfFirst { (it as? MediaViewerPageData.MediaViewerData)?.eventId == eventId }.takeIf { it >= 0 }
|
||||
return dataFlow.value.indexOfFirst {
|
||||
val pageData = it as? MediaViewerPageData.MediaViewerData
|
||||
pageData?.eventId == eventId && (mediaSource == null || pageData.mediaSource == mediaSource)
|
||||
}.takeIf { it >= 0 }
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
|
||||
+48
-32
@@ -74,42 +74,51 @@ class MediaViewerNode(
|
||||
callback.onDone()
|
||||
}
|
||||
|
||||
private val mediaGallerySource = if (inputs.mode == MediaViewerEntryPoint.MediaViewerMode.SingleMedia) {
|
||||
SingleMediaGalleryDataSource.createFrom(inputs)
|
||||
} else {
|
||||
val eventId = inputs.eventId
|
||||
if (eventId == null) {
|
||||
// Should not happen
|
||||
timelineMediaGalleryDataSource
|
||||
} else {
|
||||
// Can we use a specific timeline?
|
||||
val timelineMode = inputs.mode.getTimelineMode()
|
||||
when (timelineMode) {
|
||||
null -> timelineMediaGalleryDataSource
|
||||
Timeline.Mode.Live,
|
||||
is Timeline.Mode.FocusedOnEvent,
|
||||
is Timeline.Mode.Thread -> {
|
||||
// Does timelineMediaGalleryDataSource knows the eventId?
|
||||
val lastData = timelineMediaGalleryDataSource.getLastData().dataOrNull()
|
||||
val isEventKnown = lastData?.hasEvent(eventId) == true
|
||||
if (isEventKnown) {
|
||||
timelineMediaGalleryDataSource
|
||||
} else {
|
||||
private val mediaGallerySource = when (inputs) {
|
||||
is MediaViewerEntryPoint.Params.Avatar ->
|
||||
SingleMediaGalleryDataSource.createFrom(inputs)
|
||||
is MediaViewerEntryPoint.Params.EventGallery ->
|
||||
GalleryMediaGalleryDataSource.createFrom(
|
||||
eventId = inputs.eventId,
|
||||
galleryItems = inputs.galleryItems,
|
||||
galleryInfo = inputs.galleryInfo,
|
||||
)
|
||||
is MediaViewerEntryPoint.Params.RoomMedia -> {
|
||||
val eventId = inputs.eventId
|
||||
if (eventId == null) {
|
||||
// Should not happen
|
||||
timelineMediaGalleryDataSource
|
||||
} else {
|
||||
// Can we use a specific timeline?
|
||||
val timelineMode = inputs.mode.getTimelineMode()
|
||||
when (timelineMode) {
|
||||
Timeline.Mode.Live,
|
||||
is Timeline.Mode.FocusedOnEvent,
|
||||
is Timeline.Mode.Thread -> {
|
||||
// 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(),
|
||||
onlyPinnedEvents = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
Timeline.Mode.PinnedEvents -> {
|
||||
focusedTimelineMediaGalleryDataSourceFactory.createFor(
|
||||
eventId = eventId,
|
||||
mediaItem = inputs.toMediaItem(),
|
||||
onlyPinnedEvents = false,
|
||||
onlyPinnedEvents = true,
|
||||
)
|
||||
}
|
||||
Timeline.Mode.Media -> timelineMediaGalleryDataSource
|
||||
// null should not happen, input should be MediaViewerEntryPoint.Params.EventGallery in this case
|
||||
null -> timelineMediaGalleryDataSource
|
||||
}
|
||||
Timeline.Mode.PinnedEvents -> {
|
||||
focusedTimelineMediaGalleryDataSourceFactory.createFor(
|
||||
eventId = eventId,
|
||||
mediaItem = inputs.toMediaItem(),
|
||||
onlyPinnedEvents = true,
|
||||
)
|
||||
}
|
||||
Timeline.Mode.Media -> timelineMediaGalleryDataSource
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,7 +127,14 @@ class MediaViewerNode(
|
||||
inputs = inputs,
|
||||
navigator = this,
|
||||
dataSource = MediaViewerDataSource(
|
||||
mode = inputs.mode,
|
||||
mode = when (inputs) {
|
||||
is MediaViewerEntryPoint.Params.Avatar ->
|
||||
MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media)
|
||||
is MediaViewerEntryPoint.Params.EventGallery ->
|
||||
MediaViewerEntryPoint.MediaViewerMode.EventGallery(fromPinnedMessages = inputs.fromPinnedMessages)
|
||||
is MediaViewerEntryPoint.Params.RoomMedia ->
|
||||
inputs.mode
|
||||
},
|
||||
coroutineScope = lifecycleScope,
|
||||
dispatcher = coroutineDispatchers.computation,
|
||||
galleryDataSource = mediaGallerySource,
|
||||
@@ -153,6 +169,6 @@ internal fun MediaViewerEntryPoint.MediaViewerMode.getTimelineMode(): Timeline.M
|
||||
return when (this) {
|
||||
is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> timelineMode
|
||||
is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> timelineMode
|
||||
else -> null
|
||||
is MediaViewerEntryPoint.MediaViewerMode.EventGallery -> null
|
||||
}
|
||||
}
|
||||
|
||||
+47
-13
@@ -67,20 +67,31 @@ class MediaViewerPresenter(
|
||||
// Use a local snackbarDispatcher because this presenter is used in an Overlay Node
|
||||
private val snackbarDispatcher = SnackbarDispatcher()
|
||||
|
||||
private val eventId = inputs.eventId()
|
||||
private val mediaSource = inputs.mediaSource()
|
||||
|
||||
@Composable
|
||||
override fun present(): MediaViewerState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val currentIndex = remember { mutableIntStateOf(dataSource.findEventIndex(inputs.eventId) ?: 0) }
|
||||
val currentIndex = remember {
|
||||
val firstIndex = if (inputs is MediaViewerEntryPoint.Params.EventGallery) {
|
||||
// Order is reversed so we have to reverse the index
|
||||
inputs.galleryItems.lastIndex - inputs.galleryInfo.initialIndex
|
||||
} else {
|
||||
dataSource.findEventIndex(eventId, mediaSource) ?: 0
|
||||
}
|
||||
mutableIntStateOf(firstIndex)
|
||||
}
|
||||
val data = dataSource.produceState { flow ->
|
||||
flow.collectLatest { new ->
|
||||
val existingItem = value.getOrNull(currentIndex.intValue)
|
||||
val newItem = new.getOrNull(currentIndex.intValue)
|
||||
if (existingItem is MediaViewerPageData.MediaViewerData && existingItem.eventId == inputs.eventId && newItem != existingItem) {
|
||||
currentIndex.intValue = dataSource.findEventIndex(inputs.eventId) ?: 0
|
||||
if (existingItem is MediaViewerPageData.MediaViewerData && existingItem.eventId == eventId && newItem != existingItem) {
|
||||
currentIndex.intValue = dataSource.findEventIndex(eventId, mediaSource) ?: 0
|
||||
} else if (currentIndex.intValue > 0 && value.firstOrNull() is MediaViewerPageData.Loading &&
|
||||
new.firstOrNull() !is MediaViewerPageData.Loading) {
|
||||
// Restore index based on the eventId after the initial items have been loaded
|
||||
currentIndex.intValue = dataSource.findEventIndex(inputs.eventId) ?: 0
|
||||
currentIndex.intValue = dataSource.findEventIndex(eventId, mediaSource) ?: 0
|
||||
}
|
||||
value = new
|
||||
}
|
||||
@@ -140,7 +151,15 @@ class MediaViewerPresenter(
|
||||
mediaBottomSheetState = MediaBottomSheetState.Hidden
|
||||
navigator.onForwardClick(
|
||||
eventId = event.eventId,
|
||||
fromPinnedEvents = inputs.mode.getTimelineMode() == Timeline.Mode.PinnedEvents,
|
||||
fromPinnedEvents = when (inputs) {
|
||||
is MediaViewerEntryPoint.Params.RoomMedia -> when (val myMode = inputs.mode) {
|
||||
is MediaViewerEntryPoint.MediaViewerMode.EventGallery -> myMode.fromPinnedMessages
|
||||
is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> myMode.getTimelineMode() == Timeline.Mode.PinnedEvents
|
||||
is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> myMode.getTimelineMode() == Timeline.Mode.PinnedEvents
|
||||
}
|
||||
is MediaViewerEntryPoint.Params.EventGallery -> inputs.fromPinnedMessages
|
||||
is MediaViewerEntryPoint.Params.Avatar -> false
|
||||
},
|
||||
)
|
||||
}
|
||||
is MediaViewerEvent.OpenInfo -> coroutineScope.launch {
|
||||
@@ -176,11 +195,11 @@ class MediaViewerPresenter(
|
||||
}
|
||||
|
||||
return MediaViewerState(
|
||||
initiallySelectedEventId = inputs.eventId,
|
||||
initiallySelectedEventId = eventId,
|
||||
listData = data.value,
|
||||
currentIndex = currentIndex.intValue,
|
||||
snackbarMessage = snackbarMessage,
|
||||
canShowInfo = inputs.canShowInfo,
|
||||
canShowInfo = inputs !is MediaViewerEntryPoint.Params.Avatar,
|
||||
mediaBottomSheetState = mediaBottomSheetState,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
@@ -224,13 +243,16 @@ class MediaViewerPresenter(
|
||||
}
|
||||
|
||||
private fun showNoMoreItemsSnackbar() {
|
||||
val messageResId = when (inputs.mode) {
|
||||
MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
|
||||
is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> R.string.screen_media_details_no_more_media_to_show
|
||||
is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> R.string.screen_media_details_no_more_files_to_show
|
||||
if (inputs is MediaViewerEntryPoint.Params.RoomMedia) {
|
||||
val messageResId = when (inputs.mode) {
|
||||
is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> R.string.screen_media_details_no_more_media_to_show
|
||||
is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> R.string.screen_media_details_no_more_files_to_show
|
||||
// Should not happen
|
||||
is MediaViewerEntryPoint.MediaViewerMode.EventGallery -> R.string.screen_media_details_no_more_media_to_show
|
||||
}
|
||||
val message = SnackbarMessage(messageResId)
|
||||
snackbarDispatcher.post(message)
|
||||
}
|
||||
val message = SnackbarMessage(messageResId)
|
||||
snackbarDispatcher.post(message)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.downloadMedia(
|
||||
@@ -292,3 +314,15 @@ class MediaViewerPresenter(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun MediaViewerEntryPoint.Params.eventId() = when (this) {
|
||||
is MediaViewerEntryPoint.Params.Avatar -> null
|
||||
is MediaViewerEntryPoint.Params.EventGallery -> eventId
|
||||
is MediaViewerEntryPoint.Params.RoomMedia -> eventId
|
||||
}
|
||||
|
||||
private fun MediaViewerEntryPoint.Params.mediaSource() = when (this) {
|
||||
is MediaViewerEntryPoint.Params.Avatar -> mediaSource
|
||||
is MediaViewerEntryPoint.Params.EventGallery -> null
|
||||
is MediaViewerEntryPoint.Params.RoomMedia -> mediaSource
|
||||
}
|
||||
|
||||
+13
-11
@@ -11,6 +11,7 @@ package io.element.android.libraries.mediaviewer.impl.viewer
|
||||
import dev.zacsweers.metro.Inject
|
||||
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.mediaSource
|
||||
|
||||
/**
|
||||
* x and y are loading items.
|
||||
@@ -50,31 +51,32 @@ class PagerKeysHandler {
|
||||
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
|
||||
// Search a common item in both lists using eventId + mediaSource to handle gallery items
|
||||
val itemInCacheIndex = cachedData.mediaItems.indexOfFirst { cachedItem ->
|
||||
cachedItem is MediaItem.Event && mediaItems
|
||||
.filterIsInstance<MediaItem.Event>()
|
||||
.any { mediaItem.eventId() == it.eventId() }
|
||||
.any { newItem ->
|
||||
cachedItem.eventId() == newItem.eventId() &&
|
||||
cachedItem.mediaSource().safeUrl == newItem.mediaSource().safeUrl
|
||||
}
|
||||
}
|
||||
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
|
||||
val cachedSourceUrl = (cachedItem as? MediaItem.Event)?.mediaSource()?.safeUrl
|
||||
if (eventId == null || cachedSourceUrl == null) {
|
||||
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
|
||||
mediaItem is MediaItem.Event &&
|
||||
mediaItem.eventId() == eventId &&
|
||||
mediaItem.mediaSource().safeUrl == cachedSourceUrl
|
||||
}
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
+28
-3
@@ -9,12 +9,14 @@
|
||||
package io.element.android.libraries.mediaviewer.impl.viewer
|
||||
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
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.MediaInfo
|
||||
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
|
||||
@@ -36,17 +38,40 @@ class SingleMediaGalleryDataSource(
|
||||
override suspend fun deleteItem(eventId: EventId) = Unit
|
||||
|
||||
companion object {
|
||||
fun createFrom(params: MediaViewerEntryPoint.Params) = SingleMediaGalleryDataSource(
|
||||
fun createFrom(params: MediaViewerEntryPoint.Params.Avatar) = SingleMediaGalleryDataSource(
|
||||
data = GroupedMediaItems(
|
||||
// Always use imageAndVideoItems, in Single mode, this is the data that will be used
|
||||
imageAndVideoItems = persistentListOf(params.toMediaItem()),
|
||||
imageAndVideoItems = persistentListOf(
|
||||
MediaItem.Image(
|
||||
id = UniqueId("dummy"),
|
||||
eventId = null,
|
||||
mediaInfo = MediaInfo(
|
||||
filename = params.avatarInfo.filename,
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
mimeType = MimeTypes.Images,
|
||||
fileSize = null,
|
||||
formattedFileSize = "",
|
||||
fileExtension = "",
|
||||
senderId = null,
|
||||
senderName = null,
|
||||
senderAvatar = null,
|
||||
dateSent = null,
|
||||
dateSentFull = null,
|
||||
waveform = null,
|
||||
duration = null,
|
||||
),
|
||||
mediaSource = params.mediaSource,
|
||||
thumbnailSource = params.thumbnailSource,
|
||||
)
|
||||
),
|
||||
fileItems = persistentListOf(),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun MediaViewerEntryPoint.Params.toMediaItem() = when {
|
||||
fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
|
||||
mediaInfo.mimeType.isMimeTypeImage() -> {
|
||||
MediaItem.Image(
|
||||
id = UniqueId("dummy"),
|
||||
|
||||
+4
-23
@@ -13,14 +13,12 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.enterprise.test.FakeEnterpriseService
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
|
||||
import io.element.android.libraries.mediaplayer.test.FakeAudioFocus
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.AvatarInfo
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||
import io.element.android.libraries.mediaviewer.impl.datasource.createTimelineMediaGalleryDataSource
|
||||
import io.element.android.libraries.mediaviewer.impl.viewer.MediaViewerNode
|
||||
@@ -128,7 +126,7 @@ class DefaultMediaViewerEntryPointTest {
|
||||
override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError()
|
||||
}
|
||||
val params = entryPoint.createParamsForAvatar(
|
||||
filename = "fn",
|
||||
filename = "avatar.png",
|
||||
avatarUrl = "avatarUrl",
|
||||
)
|
||||
val result = entryPoint.createNode(
|
||||
@@ -139,27 +137,10 @@ class DefaultMediaViewerEntryPointTest {
|
||||
)
|
||||
assertThat(result).isInstanceOf(MediaViewerNode::class.java)
|
||||
assertThat(result.plugins).contains(
|
||||
MediaViewerEntryPoint.Params(
|
||||
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
|
||||
eventId = null,
|
||||
mediaInfo = MediaInfo(
|
||||
filename = "fn",
|
||||
fileSize = null,
|
||||
caption = null,
|
||||
mimeType = MimeTypes.Images,
|
||||
formattedFileSize = "",
|
||||
fileExtension = "",
|
||||
senderId = UserId("@dummy:server.org"),
|
||||
senderName = null,
|
||||
senderAvatar = null,
|
||||
dateSent = null,
|
||||
dateSentFull = null,
|
||||
waveform = null,
|
||||
duration = null,
|
||||
),
|
||||
MediaViewerEntryPoint.Params.Avatar(
|
||||
avatarInfo = AvatarInfo(filename = "avatar.png"),
|
||||
mediaSource = MediaSource(url = "avatarUrl"),
|
||||
thumbnailSource = null,
|
||||
canShowInfo = false,
|
||||
)
|
||||
)
|
||||
assertThat(result.plugins).contains(callback)
|
||||
|
||||
+328
-9
@@ -12,6 +12,7 @@ import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import io.element.android.libraries.matrix.api.media.AudioDetails
|
||||
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||
@@ -26,6 +27,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageT
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryItemType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
|
||||
@@ -57,7 +60,8 @@ import kotlinx.collections.immutable.toImmutableList
|
||||
import org.junit.Test
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class DefaultEventItemFactoryTest {
|
||||
@Suppress("LargeClass")
|
||||
class EventItemFactoryTest {
|
||||
@Test
|
||||
fun `create check all null cases`() {
|
||||
val factory = createEventItemFactory()
|
||||
@@ -97,7 +101,7 @@ class DefaultEventItemFactoryTest {
|
||||
)
|
||||
)
|
||||
)
|
||||
assertThat(result).isNull()
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +126,7 @@ class DefaultEventItemFactoryTest {
|
||||
)
|
||||
)
|
||||
)
|
||||
assertThat(result).isNull()
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +154,7 @@ class DefaultEventItemFactoryTest {
|
||||
)
|
||||
)
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
assertThat(result).containsExactly(
|
||||
MediaItem.File(
|
||||
id = A_UNIQUE_ID,
|
||||
eventId = AN_EVENT_ID,
|
||||
@@ -201,7 +205,7 @@ class DefaultEventItemFactoryTest {
|
||||
)
|
||||
)
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
assertThat(result).containsExactly(
|
||||
MediaItem.Image(
|
||||
id = A_UNIQUE_ID,
|
||||
eventId = AN_EVENT_ID,
|
||||
@@ -249,7 +253,7 @@ class DefaultEventItemFactoryTest {
|
||||
)
|
||||
)
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
assertThat(result).containsExactly(
|
||||
MediaItem.Audio(
|
||||
id = A_UNIQUE_ID,
|
||||
eventId = AN_EVENT_ID,
|
||||
@@ -301,7 +305,7 @@ class DefaultEventItemFactoryTest {
|
||||
)
|
||||
)
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
assertThat(result).containsExactly(
|
||||
MediaItem.Video(
|
||||
id = A_UNIQUE_ID,
|
||||
eventId = AN_EVENT_ID,
|
||||
@@ -353,7 +357,7 @@ class DefaultEventItemFactoryTest {
|
||||
)
|
||||
)
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
assertThat(result).containsExactly(
|
||||
MediaItem.Voice(
|
||||
id = A_UNIQUE_ID,
|
||||
eventId = AN_EVENT_ID,
|
||||
@@ -377,6 +381,321 @@ class DefaultEventItemFactoryTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create for GalleryMessageType with image item`() {
|
||||
val factory = createEventItemFactory()
|
||||
val result = factory.create(
|
||||
MatrixTimelineItem.Event(
|
||||
uniqueId = A_UNIQUE_ID,
|
||||
event = anEventTimelineItem(
|
||||
content = aMessageContent(
|
||||
messageType = GalleryMessageType(
|
||||
body = "Gallery body",
|
||||
formatted = null,
|
||||
items = listOf(
|
||||
GalleryItemType.Image(
|
||||
content = ImageMessageType(
|
||||
filename = "image.jpg",
|
||||
caption = "caption",
|
||||
formattedCaption = null,
|
||||
source = MediaSource("image_url"),
|
||||
info = ImageInfo(
|
||||
mimetype = MimeTypes.Jpeg,
|
||||
size = 123L,
|
||||
thumbnailInfo = null,
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
height = 1L,
|
||||
width = 2L,
|
||||
blurhash = null,
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
assertThat(result).containsExactly(
|
||||
MediaItem.Image(
|
||||
id = UniqueId("aUniqueId_0"),
|
||||
eventId = AN_EVENT_ID,
|
||||
mediaInfo = MediaInfo(
|
||||
mimeType = MimeTypes.Jpeg,
|
||||
filename = "image.jpg",
|
||||
fileSize = 123L,
|
||||
caption = "caption",
|
||||
formattedFileSize = "123 Bytes",
|
||||
fileExtension = "jpg",
|
||||
senderId = A_USER_ID,
|
||||
senderName = "alice",
|
||||
senderAvatar = null,
|
||||
dateSent = "0 Day false",
|
||||
dateSentFull = "0 Full false",
|
||||
waveform = null,
|
||||
duration = null,
|
||||
),
|
||||
mediaSource = MediaSource("image_url"),
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create for GalleryMessageType with video item`() {
|
||||
val factory = createEventItemFactory()
|
||||
val result = factory.create(
|
||||
MatrixTimelineItem.Event(
|
||||
uniqueId = A_UNIQUE_ID,
|
||||
event = anEventTimelineItem(
|
||||
content = aMessageContent(
|
||||
messageType = GalleryMessageType(
|
||||
body = "Gallery body",
|
||||
formatted = null,
|
||||
items = listOf(
|
||||
GalleryItemType.Video(
|
||||
content = VideoMessageType(
|
||||
filename = "video.mp4",
|
||||
caption = "caption",
|
||||
formattedCaption = null,
|
||||
source = MediaSource("video_url"),
|
||||
info = VideoInfo(
|
||||
mimetype = MimeTypes.Mp4,
|
||||
size = 123L,
|
||||
thumbnailInfo = null,
|
||||
duration = 123.seconds,
|
||||
height = 1L,
|
||||
width = 2L,
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
blurhash = null,
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
assertThat(result).containsExactly(
|
||||
MediaItem.Video(
|
||||
id = UniqueId("aUniqueId_0"),
|
||||
eventId = AN_EVENT_ID,
|
||||
mediaInfo = MediaInfo(
|
||||
mimeType = MimeTypes.Mp4,
|
||||
filename = "video.mp4",
|
||||
fileSize = 123L,
|
||||
caption = "caption",
|
||||
formattedFileSize = "123 Bytes",
|
||||
fileExtension = "mp4",
|
||||
senderId = A_USER_ID,
|
||||
senderName = "alice",
|
||||
senderAvatar = null,
|
||||
dateSent = "0 Day false",
|
||||
dateSentFull = "0 Full false",
|
||||
waveform = null,
|
||||
duration = "2:03",
|
||||
),
|
||||
mediaSource = MediaSource("video_url"),
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create for GalleryMessageType with audio item`() {
|
||||
val factory = createEventItemFactory()
|
||||
val result = factory.create(
|
||||
MatrixTimelineItem.Event(
|
||||
uniqueId = A_UNIQUE_ID,
|
||||
event = anEventTimelineItem(
|
||||
content = aMessageContent(
|
||||
messageType = GalleryMessageType(
|
||||
body = "Gallery body",
|
||||
formatted = null,
|
||||
items = listOf(
|
||||
GalleryItemType.Audio(
|
||||
content = AudioMessageType(
|
||||
filename = "audio.mp3",
|
||||
caption = "caption",
|
||||
formattedCaption = null,
|
||||
source = MediaSource("audio_url"),
|
||||
info = AudioInfo(
|
||||
mimetype = MimeTypes.Mp3,
|
||||
size = 123L,
|
||||
duration = 456.seconds,
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
assertThat(result).containsExactly(
|
||||
MediaItem.Audio(
|
||||
id = UniqueId("aUniqueId_0"),
|
||||
eventId = AN_EVENT_ID,
|
||||
mediaInfo = MediaInfo(
|
||||
mimeType = MimeTypes.Mp3,
|
||||
filename = "audio.mp3",
|
||||
fileSize = 123L,
|
||||
caption = "caption",
|
||||
formattedFileSize = "123 Bytes",
|
||||
fileExtension = "mp3",
|
||||
senderId = A_USER_ID,
|
||||
senderName = "alice",
|
||||
senderAvatar = null,
|
||||
dateSent = "0 Day false",
|
||||
dateSentFull = "0 Full false",
|
||||
waveform = null,
|
||||
duration = null,
|
||||
),
|
||||
mediaSource = MediaSource("audio_url"),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create for GalleryMessageType with file item`() {
|
||||
val factory = createEventItemFactory()
|
||||
val result = factory.create(
|
||||
MatrixTimelineItem.Event(
|
||||
uniqueId = A_UNIQUE_ID,
|
||||
event = anEventTimelineItem(
|
||||
content = aMessageContent(
|
||||
messageType = GalleryMessageType(
|
||||
body = "Gallery body",
|
||||
formatted = null,
|
||||
items = listOf(
|
||||
GalleryItemType.File(
|
||||
content = FileMessageType(
|
||||
filename = "document.pdf",
|
||||
caption = "caption",
|
||||
formattedCaption = null,
|
||||
source = MediaSource("file_url"),
|
||||
info = FileInfo(
|
||||
mimetype = MimeTypes.Pdf,
|
||||
size = 456L,
|
||||
thumbnailInfo = null,
|
||||
thumbnailSource = null,
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
assertThat(result).containsExactly(
|
||||
MediaItem.File(
|
||||
id = UniqueId("aUniqueId_0"),
|
||||
eventId = AN_EVENT_ID,
|
||||
mediaInfo = MediaInfo(
|
||||
mimeType = MimeTypes.Pdf,
|
||||
filename = "document.pdf",
|
||||
fileSize = 456L,
|
||||
caption = "caption",
|
||||
formattedFileSize = "456 Bytes",
|
||||
fileExtension = "pdf",
|
||||
senderId = A_USER_ID,
|
||||
senderName = "alice",
|
||||
senderAvatar = null,
|
||||
dateSent = "0 Day false",
|
||||
dateSentFull = "0 Full false",
|
||||
waveform = null,
|
||||
duration = null,
|
||||
),
|
||||
mediaSource = MediaSource("file_url"),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create for GalleryMessageType with Other item returns empty list`() {
|
||||
val factory = createEventItemFactory()
|
||||
val result = factory.create(
|
||||
MatrixTimelineItem.Event(
|
||||
uniqueId = A_UNIQUE_ID,
|
||||
event = anEventTimelineItem(
|
||||
content = aMessageContent(
|
||||
messageType = GalleryMessageType(
|
||||
body = "Gallery body",
|
||||
formatted = null,
|
||||
items = listOf(
|
||||
GalleryItemType.Other(itemType = "unknown_type", body = "Some body")
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create for GalleryMessageType with multiple items produces indexed IDs`() {
|
||||
val factory = createEventItemFactory()
|
||||
val result = factory.create(
|
||||
MatrixTimelineItem.Event(
|
||||
uniqueId = A_UNIQUE_ID,
|
||||
event = anEventTimelineItem(
|
||||
content = aMessageContent(
|
||||
messageType = GalleryMessageType(
|
||||
body = "Gallery body",
|
||||
formatted = null,
|
||||
items = listOf(
|
||||
GalleryItemType.Image(
|
||||
content = ImageMessageType(
|
||||
filename = "image.jpg",
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
source = MediaSource("image_url"),
|
||||
info = ImageInfo(
|
||||
mimetype = MimeTypes.Jpeg,
|
||||
size = 123L,
|
||||
thumbnailInfo = null,
|
||||
thumbnailSource = null,
|
||||
height = 1L,
|
||||
width = 2L,
|
||||
blurhash = null,
|
||||
)
|
||||
)
|
||||
),
|
||||
GalleryItemType.Video(
|
||||
content = VideoMessageType(
|
||||
filename = "video.mp4",
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
source = MediaSource("video_url"),
|
||||
info = VideoInfo(
|
||||
mimetype = MimeTypes.Mp4,
|
||||
size = 456L,
|
||||
thumbnailInfo = null,
|
||||
duration = null,
|
||||
height = 1L,
|
||||
width = 2L,
|
||||
thumbnailSource = null,
|
||||
blurhash = null,
|
||||
)
|
||||
)
|
||||
),
|
||||
GalleryItemType.Other(itemType = "unknown_type", body = "ignored"),
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
assertThat(result).hasSize(2)
|
||||
assertThat(result[0]).isInstanceOf(MediaItem.Image::class.java)
|
||||
assertThat((result[0] as MediaItem.Image).id).isEqualTo(UniqueId("aUniqueId_0"))
|
||||
assertThat(result[1]).isInstanceOf(MediaItem.Video::class.java)
|
||||
assertThat((result[1] as MediaItem.Video).id).isEqualTo(UniqueId("aUniqueId_1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create for StickerMessageType`() {
|
||||
val factory = createEventItemFactory()
|
||||
@@ -404,7 +723,7 @@ class DefaultEventItemFactoryTest {
|
||||
)
|
||||
)
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
assertThat(result).containsExactly(
|
||||
MediaItem.Image(
|
||||
id = A_UNIQUE_ID,
|
||||
eventId = AN_EVENT_ID,
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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 app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
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.A_USER_ID
|
||||
import io.element.android.libraries.mediaviewer.api.GalleryInfo
|
||||
import io.element.android.libraries.mediaviewer.api.GalleryItemData
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
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
|
||||
import org.junit.Test
|
||||
|
||||
class GalleryMediaGalleryDataSourceTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
@Test
|
||||
fun `isReady is true`() {
|
||||
val sut = GalleryMediaGalleryDataSource(aGroupedMediaItems())
|
||||
assertThat(sut.isReady).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `function start is no op`() = runTest {
|
||||
val sut = GalleryMediaGalleryDataSource(aGroupedMediaItems())
|
||||
sut.start(backgroundScope)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `function loadMore is no op`() = runTest {
|
||||
val sut = GalleryMediaGalleryDataSource(aGroupedMediaItems())
|
||||
sut.loadMore(Timeline.PaginationDirection.BACKWARDS)
|
||||
sut.loadMore(Timeline.PaginationDirection.FORWARDS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `function deleteItem is no op`() = runTest {
|
||||
val sut = GalleryMediaGalleryDataSource(aGroupedMediaItems())
|
||||
sut.deleteItem(AN_EVENT_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getLastData should return the data`() {
|
||||
val data = aGroupedMediaItems(
|
||||
imageAndVideoItems = listOf(aMediaItemImage()),
|
||||
fileItems = listOf(aMediaItemFile()),
|
||||
)
|
||||
val sut = GalleryMediaGalleryDataSource(data)
|
||||
assertThat(sut.getLastData()).isEqualTo(AsyncData.Success(data))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `groupedMediaItemsFlow emits a single item`() = runTest {
|
||||
val data = aGroupedMediaItems(
|
||||
imageAndVideoItems = listOf(aMediaItemImage()),
|
||||
fileItems = listOf(aMediaItemFile()),
|
||||
)
|
||||
val sut = GalleryMediaGalleryDataSource(data)
|
||||
sut.groupedMediaItemsFlow().test {
|
||||
assertThat(awaitItem()).isEqualTo(AsyncData.Success(data))
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createFrom with image item creates MediaItem Image in imageAndVideoItems`() {
|
||||
val result = GalleryMediaGalleryDataSource.createFrom(
|
||||
eventId = AN_EVENT_ID,
|
||||
galleryItems = listOf(
|
||||
GalleryItemData(
|
||||
filename = "image.jpg",
|
||||
mimeType = MimeTypes.Jpeg,
|
||||
mediaSource = MediaSource("image_url"),
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
type = GalleryItemData.Type.Image,
|
||||
)
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
)
|
||||
val data = (result.getLastData() as AsyncData.Success).data
|
||||
assertThat(data.fileItems).isEmpty()
|
||||
assertThat(data.imageAndVideoItems).containsExactly(
|
||||
MediaItem.Image(
|
||||
id = UniqueId("${AN_EVENT_ID.value}_0"),
|
||||
eventId = AN_EVENT_ID,
|
||||
mediaInfo = expectedMediaInfo("image.jpg", MimeTypes.Jpeg),
|
||||
mediaSource = MediaSource("image_url"),
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createFrom with video item creates MediaItem Video in imageAndVideoItems`() {
|
||||
val result = GalleryMediaGalleryDataSource.createFrom(
|
||||
eventId = AN_EVENT_ID,
|
||||
galleryItems = listOf(
|
||||
GalleryItemData(
|
||||
filename = "video.mp4",
|
||||
mimeType = MimeTypes.Mp4,
|
||||
mediaSource = MediaSource("video_url"),
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
type = GalleryItemData.Type.Video,
|
||||
)
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
)
|
||||
val data = (result.getLastData() as AsyncData.Success).data
|
||||
assertThat(data.fileItems).isEmpty()
|
||||
assertThat(data.imageAndVideoItems).containsExactly(
|
||||
MediaItem.Video(
|
||||
id = UniqueId("${AN_EVENT_ID.value}_0"),
|
||||
eventId = AN_EVENT_ID,
|
||||
mediaInfo = expectedMediaInfo("video.mp4", MimeTypes.Mp4),
|
||||
mediaSource = MediaSource("video_url"),
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createFrom with audio item creates MediaItem Audio in imageAndVideoItems`() {
|
||||
val result = GalleryMediaGalleryDataSource.createFrom(
|
||||
eventId = AN_EVENT_ID,
|
||||
galleryItems = listOf(
|
||||
GalleryItemData(
|
||||
filename = "audio.mp3",
|
||||
mimeType = MimeTypes.Mp3,
|
||||
mediaSource = MediaSource("audio_url"),
|
||||
thumbnailSource = null,
|
||||
type = GalleryItemData.Type.Audio,
|
||||
)
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
)
|
||||
val data = (result.getLastData() as AsyncData.Success).data
|
||||
assertThat(data.fileItems).isEmpty()
|
||||
assertThat(data.imageAndVideoItems).containsExactly(
|
||||
MediaItem.Audio(
|
||||
id = UniqueId("${AN_EVENT_ID.value}_0"),
|
||||
eventId = AN_EVENT_ID,
|
||||
mediaInfo = expectedMediaInfo("audio.mp3", MimeTypes.Mp3),
|
||||
mediaSource = MediaSource("audio_url"),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createFrom with file item creates MediaItem File in imageAndVideoItems`() {
|
||||
val result = GalleryMediaGalleryDataSource.createFrom(
|
||||
eventId = AN_EVENT_ID,
|
||||
galleryItems = listOf(
|
||||
GalleryItemData(
|
||||
filename = "document.pdf",
|
||||
mimeType = MimeTypes.Pdf,
|
||||
mediaSource = MediaSource("file_url"),
|
||||
thumbnailSource = null,
|
||||
type = GalleryItemData.Type.File,
|
||||
)
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
)
|
||||
val data = (result.getLastData() as AsyncData.Success).data
|
||||
assertThat(data.fileItems).isEmpty()
|
||||
assertThat(data.imageAndVideoItems).containsExactly(
|
||||
MediaItem.File(
|
||||
id = UniqueId("${AN_EVENT_ID.value}_0"),
|
||||
eventId = AN_EVENT_ID,
|
||||
mediaInfo = expectedMediaInfo("document.pdf", MimeTypes.Pdf),
|
||||
mediaSource = MediaSource("file_url"),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createFrom uses gallery prefix when eventId is null`() {
|
||||
val result = GalleryMediaGalleryDataSource.createFrom(
|
||||
eventId = null,
|
||||
galleryItems = listOf(
|
||||
GalleryItemData(
|
||||
filename = "image.jpg",
|
||||
mimeType = MimeTypes.Jpeg,
|
||||
mediaSource = MediaSource("image_url"),
|
||||
thumbnailSource = null,
|
||||
type = GalleryItemData.Type.Image,
|
||||
)
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
)
|
||||
val data = (result.getLastData() as AsyncData.Success).data
|
||||
val item = data.imageAndVideoItems.single() as MediaItem.Image
|
||||
assertThat(item.id).isEqualTo(UniqueId("gallery_0"))
|
||||
assertThat(item.eventId).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createFrom with multiple items produces indexed IDs and all go to imageAndVideoItems`() {
|
||||
val result = GalleryMediaGalleryDataSource.createFrom(
|
||||
eventId = AN_EVENT_ID,
|
||||
galleryItems = listOf(
|
||||
GalleryItemData(
|
||||
filename = "image.jpg",
|
||||
mimeType = MimeTypes.Jpeg,
|
||||
mediaSource = MediaSource("image_url"),
|
||||
thumbnailSource = null,
|
||||
type = GalleryItemData.Type.Image,
|
||||
),
|
||||
GalleryItemData(
|
||||
filename = "document.pdf",
|
||||
mimeType = MimeTypes.Pdf,
|
||||
mediaSource = MediaSource("file_url"),
|
||||
thumbnailSource = null,
|
||||
type = GalleryItemData.Type.File,
|
||||
),
|
||||
GalleryItemData(
|
||||
filename = "video.mp4",
|
||||
mimeType = MimeTypes.Mp4,
|
||||
mediaSource = MediaSource("video_url"),
|
||||
thumbnailSource = null,
|
||||
type = GalleryItemData.Type.Video,
|
||||
),
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
)
|
||||
val data = (result.getLastData() as AsyncData.Success).data
|
||||
assertThat(data.fileItems).isEmpty()
|
||||
assertThat(data.imageAndVideoItems).hasSize(3)
|
||||
assertThat((data.imageAndVideoItems[0] as MediaItem.Image).id).isEqualTo(UniqueId("${AN_EVENT_ID.value}_0"))
|
||||
assertThat((data.imageAndVideoItems[1] as MediaItem.File).id).isEqualTo(UniqueId("${AN_EVENT_ID.value}_1"))
|
||||
assertThat((data.imageAndVideoItems[2] as MediaItem.Video).id).isEqualTo(UniqueId("${AN_EVENT_ID.value}_2"))
|
||||
}
|
||||
|
||||
private fun aGalleryInfo() = GalleryInfo(
|
||||
caption = "A caption",
|
||||
formattedCaption = null,
|
||||
senderId = A_USER_ID,
|
||||
senderName = "Alice",
|
||||
senderAvatar = null,
|
||||
dateSent = "Today",
|
||||
dateSentFull = "Today at 12:00",
|
||||
initialIndex = 0,
|
||||
)
|
||||
|
||||
private fun expectedMediaInfo(filename: String, mimeType: String) = MediaInfo(
|
||||
filename = filename,
|
||||
fileSize = null,
|
||||
caption = "A caption",
|
||||
mimeType = mimeType,
|
||||
formattedFileSize = "",
|
||||
fileExtension = filename.substringAfterLast('.', ""),
|
||||
senderId = A_USER_ID,
|
||||
senderName = "Alice",
|
||||
senderAvatar = null,
|
||||
dateSent = "Today",
|
||||
dateSentFull = "Today at 12:00",
|
||||
waveform = null,
|
||||
duration = null,
|
||||
)
|
||||
}
|
||||
+19
-14
@@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.mediaviewer.api.AvatarInfo
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
|
||||
@@ -109,7 +110,11 @@ class MediaViewerPresenterTest {
|
||||
fun `present - initial state cannot show info`() = runTest {
|
||||
val presenter = createMediaViewerPresenter(
|
||||
localMediaFactory = localMediaFactory,
|
||||
canShowInfo = false,
|
||||
inputs = MediaViewerEntryPoint.Params.Avatar(
|
||||
avatarInfo = AvatarInfo(filename = "avatar.png"),
|
||||
mediaSource = aMediaSource(),
|
||||
thumbnailSource = null,
|
||||
),
|
||||
room = FakeJoinedRoom(
|
||||
baseRoom = FakeBaseRoom(
|
||||
roomPermissions = FakeRoomPermissions(
|
||||
@@ -639,7 +644,7 @@ class MediaViewerPresenterTest {
|
||||
}
|
||||
)
|
||||
)
|
||||
skipItems(2)
|
||||
skipItems(1)
|
||||
val stateWithSnackbar = awaitItem()
|
||||
assertThat(stateWithSnackbar.snackbarMessage!!.messageResId).isEqualTo(expectedSnackbarResId)
|
||||
}
|
||||
@@ -932,29 +937,31 @@ class MediaViewerPresenterTest {
|
||||
|
||||
internal fun TestScope.createMediaViewerPresenter(
|
||||
localMediaFactory: LocalMediaFactory,
|
||||
inputs: MediaViewerEntryPoint.Params? = null,
|
||||
eventId: EventId? = null,
|
||||
mode: MediaViewerEntryPoint.MediaViewerMode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
|
||||
mode: MediaViewerEntryPoint.MediaViewerMode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media),
|
||||
matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(),
|
||||
localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(),
|
||||
mediaGalleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource(
|
||||
startLambda = { },
|
||||
),
|
||||
canShowInfo: Boolean = true,
|
||||
mediaViewerNavigator: MediaViewerNavigator = FakeMediaViewerNavigator(),
|
||||
room: JoinedRoom = FakeJoinedRoom(
|
||||
liveTimeline = FakeTimeline(),
|
||||
),
|
||||
): MediaViewerPresenter {
|
||||
val actualInputs = inputs ?: createMediaViewerEntryPointParams(eventId = eventId, mode = mode)
|
||||
val actualMode = when (actualInputs) {
|
||||
is MediaViewerEntryPoint.Params.Avatar -> MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media)
|
||||
is MediaViewerEntryPoint.Params.EventGallery -> MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media)
|
||||
is MediaViewerEntryPoint.Params.RoomMedia -> actualInputs.mode
|
||||
}
|
||||
return MediaViewerPresenter(
|
||||
inputs = createMediaViewerEntryPointParams(
|
||||
eventId = eventId,
|
||||
mode = mode,
|
||||
canShowInfo = canShowInfo,
|
||||
),
|
||||
inputs = actualInputs,
|
||||
navigator = mediaViewerNavigator,
|
||||
dataSource = MediaViewerDataSource(
|
||||
coroutineScope = backgroundScope,
|
||||
mode = mode,
|
||||
mode = actualMode,
|
||||
dispatcher = testCoroutineDispatchers().computation,
|
||||
galleryDataSource = mediaGalleryDataSource,
|
||||
mediaLoader = matrixMediaLoader,
|
||||
@@ -969,13 +976,11 @@ internal fun TestScope.createMediaViewerPresenter(
|
||||
|
||||
internal fun createMediaViewerEntryPointParams(
|
||||
eventId: EventId? = null,
|
||||
mode: MediaViewerEntryPoint.MediaViewerMode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
|
||||
canShowInfo: Boolean = true,
|
||||
) = MediaViewerEntryPoint.Params(
|
||||
mode: MediaViewerEntryPoint.MediaViewerMode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode = Timeline.Mode.Media),
|
||||
) = MediaViewerEntryPoint.Params.RoomMedia(
|
||||
mode = mode,
|
||||
eventId = eventId,
|
||||
mediaInfo = TESTED_MEDIA_INFO,
|
||||
mediaSource = aMediaSource(),
|
||||
thumbnailSource = null,
|
||||
canShowInfo = canShowInfo,
|
||||
)
|
||||
|
||||
+5
-8
@@ -160,23 +160,20 @@ class SingleMediaGalleryDataSourceTest {
|
||||
|
||||
private fun testFactory(
|
||||
mediaInfo: MediaInfo,
|
||||
expectedResult: (MediaViewerEntryPoint.Params) -> MediaItem,
|
||||
expectedResult: (MediaViewerEntryPoint.Params.RoomMedia) -> MediaItem,
|
||||
) {
|
||||
val params = aMediaViewerEntryPointParams(mediaInfo)
|
||||
val result = SingleMediaGalleryDataSource.createFrom(params)
|
||||
val resultData = result.getLastData().dataOrNull()
|
||||
assertThat(resultData!!.imageAndVideoItems.first()).isEqualTo(expectedResult(params))
|
||||
assertThat(resultData.fileItems).isEmpty()
|
||||
val result = params.toMediaItem()
|
||||
assertThat(result).isEqualTo(expectedResult(params))
|
||||
}
|
||||
|
||||
internal fun aMediaViewerEntryPointParams(
|
||||
mediaInfo: MediaInfo,
|
||||
) = MediaViewerEntryPoint.Params(
|
||||
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
|
||||
) = MediaViewerEntryPoint.Params.RoomMedia(
|
||||
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media),
|
||||
eventId = AN_EVENT_ID,
|
||||
mediaInfo = mediaInfo,
|
||||
mediaSource = aMediaSource(url = "aUrl"),
|
||||
thumbnailSource = aMediaSource(url = "aThumbnailUrl"),
|
||||
canShowInfo = true,
|
||||
)
|
||||
}
|
||||
|
||||
+2
@@ -41,6 +41,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageT
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
|
||||
@@ -344,6 +345,7 @@ class DefaultNotifiableEventResolver(
|
||||
is TextMessageType -> messageType.toPlainText(permalinkParser = permalinkParser)
|
||||
is VideoMessageType -> messageType.bestDescription
|
||||
is LocationMessageType -> messageType.body
|
||||
is GalleryMessageType -> messageType.body
|
||||
is OtherMessageType -> messageType.body
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user