Merge pull request #7112 from element-hq/feature/bma/galleryMessages

Support gallery messages
This commit is contained in:
Benoit Marty
2026-07-03 14:09:20 +02:00
committed by GitHub
175 changed files with 4915 additions and 590 deletions
@@ -47,9 +47,12 @@ import io.element.android.features.messages.impl.threads.list.ThreadsListNode
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.GalleryItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
@@ -86,6 +89,8 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.RoomNamesCache
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.api.MediaViewerEntryPoint
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater
@@ -153,7 +158,16 @@ class MessagesFlowNode(
) : NavTarget
@Parcelize
data class AttachmentPreview(val timelineMode: Timeline.Mode, val attachment: Attachment, val inReplyToEventId: EventId?) : NavTarget
data class GalleryViewer(
val fromPinnedMessages: Boolean,
val eventId: EventId?,
val galleryInfo: GalleryInfo,
val canUseOverlay: Boolean,
val galleryItems: List<GalleryItemData> = emptyList(),
) : NavTarget
@Parcelize
data class AttachmentPreview(val timelineMode: Timeline.Mode, val attachments: ImmutableList<Attachment>, val inReplyToEventId: EventId?) : NavTarget
@Parcelize
data class LocationViewer(val mode: ShowLocationMode) : NavTarget
@@ -239,7 +253,11 @@ class MessagesFlowNode(
callback.navigateToRoomDetails()
}
override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean {
override fun handleEventClick(
timelineMode: Timeline.Mode,
event: TimelineItem.Event,
canUseOverlay: Boolean,
): Boolean {
return processEventClick(
timelineMode = timelineMode,
event = event,
@@ -247,10 +265,24 @@ class MessagesFlowNode(
)
}
override fun handleGalleryItemClick(
timelineMode: Timeline.Mode,
event: TimelineItem.Event,
galleryItemIndex: Int,
canUseOverlay: Boolean,
): Boolean {
return processGalleryEventClick(
timelineMode = timelineMode,
event = event,
canUseOverlay = canUseOverlay,
galleryItemIndex = galleryItemIndex,
)
}
override fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) {
backstack.push(
NavTarget.AttachmentPreview(
attachment = attachments.first(),
attachments = attachments,
timelineMode = Timeline.Mode.Live,
inReplyToEventId = inReplyToEventId,
)
@@ -339,13 +371,44 @@ class MessagesFlowNode(
createNode<MessagesNode>(buildContext, listOf(callback, inputs))
}
is NavTarget.MediaViewer -> {
val params = MediaViewerEntryPoint.Params(
val params = MediaViewerEntryPoint.Params.RoomMedia(
mode = navTarget.mode,
eventId = navTarget.eventId,
mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource,
canShowInfo = true,
)
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() {
if (navTarget.canUseOverlay) {
overlay.hide()
} else {
backstack.pop()
}
}
override fun viewInTimeline(eventId: EventId) {
this@MessagesFlowNode.viewInTimeline(eventId)
}
override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) {
// Need to go to the parent because of the overlay
callback.forwardEvent(eventId, fromPinnedEvents)
}
}
mediaViewerEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = params,
callback = callback
)
}
is NavTarget.GalleryViewer -> {
val params = MediaViewerEntryPoint.Params.EventGallery(
eventId = navTarget.eventId,
galleryInfo = navTarget.galleryInfo,
galleryItems = navTarget.galleryItems,
fromPinnedMessages = navTarget.fromPinnedMessages,
)
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() {
@@ -374,7 +437,7 @@ class MessagesFlowNode(
}
is NavTarget.AttachmentPreview -> {
val inputs = AttachmentsPreviewNode.Inputs(
attachment = navTarget.attachment,
attachments = navTarget.attachments,
timelineMode = navTarget.timelineMode,
inReplyToEventId = navTarget.inReplyToEventId,
)
@@ -455,6 +518,19 @@ class MessagesFlowNode(
)
}
override fun handleGalleryItemClick(
event: TimelineItem.Event,
galleryItemIndex: Int,
canUseOverlay: Boolean,
) {
processGalleryEventClick(
timelineMode = Timeline.Mode.PinnedEvents,
event = event,
galleryItemIndex = galleryItemIndex,
canUseOverlay = canUseOverlay,
)
}
override fun navigateToRoomMemberDetails(userId: UserId) {
callback.navigateToRoomMemberDetails(userId)
}
@@ -490,7 +566,11 @@ class MessagesFlowNode(
focusedEventId = navTarget.focusedEventId,
)
val callback = object : ThreadedMessagesNode.Callback {
override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean {
override fun handleEventClick(
timelineMode: Timeline.Mode,
event: TimelineItem.Event,
canUseOverlay: Boolean,
): Boolean {
return processEventClick(
timelineMode = timelineMode,
event = event,
@@ -498,10 +578,24 @@ class MessagesFlowNode(
)
}
override fun handleGalleryItemClick(
timelineMode: Timeline.Mode,
event: TimelineItem.Event,
galleryItemIndex: Int,
canUseOverlay: Boolean,
): Boolean {
return processGalleryEventClick(
timelineMode = timelineMode,
event = event,
canUseOverlay = canUseOverlay,
galleryItemIndex = galleryItemIndex,
)
}
override fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) {
backstack.push(
NavTarget.AttachmentPreview(
attachment = attachments.first(),
attachments = attachments,
timelineMode = Timeline.Mode.Thread(navTarget.threadRootId),
inReplyToEventId = inReplyToEventId,
)
@@ -704,6 +798,92 @@ class MessagesFlowNode(
}
}
private fun processGalleryEventClick(
timelineMode: Timeline.Mode,
event: TimelineItem.Event,
galleryItemIndex: Int,
canUseOverlay: Boolean,
): Boolean {
val navTarget = when (event.content) {
is TimelineItemGalleryContent -> {
val galleryInfo = GalleryInfo(
caption = event.content.caption,
senderId = event.senderId,
senderName = event.safeSenderName,
senderAvatar = event.senderAvatar.url,
dateSent = dateFormatter.format(
event.sentTimeMillis,
mode = DateFormatterMode.Day,
),
dateSentFull = dateFormatter.format(
timestamp = event.sentTimeMillis,
mode = DateFormatterMode.Full,
),
initialIndex = galleryItemIndex,
)
val galleryItems = event.content.items.map { galleryItem ->
GalleryItemData(
filename = galleryItem.filename,
mimeType = galleryItem.mimeType,
mediaSource = galleryItem.mediaSource,
thumbnailSource = galleryItem.thumbnailSource,
type = galleryItem.type.toMediaViewerType(),
)
}.reversed()
NavTarget.GalleryViewer(
eventId = event.eventId,
galleryInfo = galleryInfo,
canUseOverlay = canUseOverlay,
galleryItems = galleryItems,
fromPinnedMessages = timelineMode is Timeline.Mode.PinnedEvents
)
}
is TimelineItemAttachmentsContent -> {
val galleryInfo = GalleryInfo(
caption = event.content.caption,
senderId = event.senderId,
senderName = event.safeSenderName,
senderAvatar = event.senderAvatar.url,
dateSent = dateFormatter.format(
event.sentTimeMillis,
mode = DateFormatterMode.Day,
),
dateSentFull = dateFormatter.format(
timestamp = event.sentTimeMillis,
mode = DateFormatterMode.Full,
),
initialIndex = galleryItemIndex,
)
val galleryItems = event.content.attachments.map { attachment ->
GalleryItemData(
filename = attachment.filename,
mimeType = attachment.mimeType,
mediaSource = attachment.mediaSource,
thumbnailSource = attachment.thumbnailSource,
type = GalleryItemData.Type.File,
)
}.reversed()
NavTarget.GalleryViewer(
eventId = event.eventId,
galleryInfo = galleryInfo,
canUseOverlay = canUseOverlay,
galleryItems = galleryItems,
fromPinnedMessages = timelineMode is Timeline.Mode.PinnedEvents
)
}
else -> null
}
if (navTarget != null) {
if (canUseOverlay) {
overlay.show(navTarget)
} else {
backstack.push(navTarget)
}
return true
}
return false
}
private fun buildMediaViewerNavTarget(
mode: MediaViewerEntryPoint.MediaViewerMode,
event: TimelineItem.Event,
@@ -768,3 +948,12 @@ class MessagesFlowNode(
}
}
}
private fun GalleryItem.Type.toMediaViewerType(): GalleryItemData.Type {
return when (this) {
GalleryItem.Type.Image -> GalleryItemData.Type.Image
GalleryItem.Type.Video -> GalleryItemData.Type.Video
GalleryItem.Type.Audio -> GalleryItemData.Type.Audio
GalleryItem.Type.File -> GalleryItemData.Type.File
}
}
@@ -119,6 +119,7 @@ class MessagesNode(
interface Callback : Plugin {
fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean
fun handleGalleryItemClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, galleryItemIndex: Int, canUseOverlay: Boolean): Boolean
fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
fun navigateToRoomMemberDetails(userId: UserId)
fun handlePermalinkClick(data: PermalinkData)
@@ -289,6 +290,18 @@ class MessagesNode(
}
}
},
onGalleryEventItemClick = { isLive, event, index ->
if (isLive) {
callback.handleGalleryItemClick(timelineController.mainTimelineMode(), event, index, canUseOverlay)
} else {
val detachedTimelineMode = timelineController.detachedTimelineMode()
if (detachedTimelineMode != null) {
callback.handleGalleryItemClick(detachedTimelineMode, event, index, canUseOverlay)
} else {
false
}
}
},
onUserDataClick = callback::navigateToRoomMemberDetails,
onLinkClick = { url, customTab ->
onLinkClick(
@@ -47,10 +47,10 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.captionOrNull
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter
import io.element.android.features.roomcall.api.RoomCallState
@@ -533,7 +533,7 @@ class MessagesPresenter(
) {
val composerMode = MessageComposerMode.EditCaption(
eventOrTransactionId = targetEvent.eventOrTransactionId,
content = (targetEvent.content as? TimelineItemEventContentWithAttachment)?.caption.orEmpty(),
content = targetEvent.content.captionOrNull().orEmpty(),
)
composerState.eventSink(
MessageComposerEvent.SetMode(composerMode)
@@ -606,7 +606,7 @@ class MessagesPresenter(
}
private fun handleCopyCaption(event: TimelineItem.Event) {
val content = (event.content as? TimelineItemEventContentWithAttachment)?.caption ?: return
val content = event.content.captionOrNull() ?: return
clipboardHelper.copyPlainText(content)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
@@ -136,6 +136,7 @@ fun MessagesView(
onBackClick: () -> Unit,
onRoomDetailsClick: () -> Unit,
onEventContentClick: (isLive: Boolean, event: TimelineItem.Event) -> Boolean,
onGalleryEventItemClick: (isLive: Boolean, event: TimelineItem.Event, index: Int) -> Boolean,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String, Boolean) -> Unit,
onSendLocationClick: () -> Unit,
@@ -207,15 +208,15 @@ fun MessagesView(
val expandableState = rememberExpandableBottomSheetLayoutState()
ExpandableBottomSheetLayout(
modifier = modifier
.fillMaxSize()
.imePadding()
.systemBarsPadding()
.onSizeChanged { size ->
// Let the composer takes at max half of the available height.
// The value will be different if the soft keyboard is displayed
// or not.
maxComposerHeightPx = (size.height * 0.5f).toInt()
},
.fillMaxSize()
.imePadding()
.systemBarsPadding()
.onSizeChanged { size ->
// Let the composer takes at max half of the available height.
// The value will be different if the soft keyboard is displayed
// or not.
maxComposerHeightPx = (size.height * 0.5f).toInt()
},
content = {
Scaffold(
contentWindowInsets = WindowInsets.statusBars,
@@ -252,12 +253,22 @@ fun MessagesView(
content = { padding ->
Box(
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
.padding(padding)
.consumeWindowInsets(padding)
) {
MessagesViewContent(
state = state,
onContentClick = ::onContentClick,
onGalleryItemClick = { event, index ->
val hideKeyboard = onGalleryEventItemClick(
state.timelineState.isLive,
event,
index,
)
if (hideKeyboard) {
localView.hideKeyboard()
}
},
onMessageLongClick = ::onMessageLongClick,
onUserDataClick = {
hidingKeyboard {
@@ -290,10 +301,10 @@ fun MessagesView(
SuggestionsPickerView(
modifier = Modifier
.shadow(10.dp)
.background(ElementTheme.colors.bgCanvasDefault)
.align(Alignment.BottomStart)
.heightIn(max = 230.dp),
.shadow(10.dp)
.background(ElementTheme.colors.bgCanvasDefault)
.align(Alignment.BottomStart)
.heightIn(max = 230.dp),
roomId = state.roomId,
roomName = state.roomName,
roomAvatarData = state.roomAvatar,
@@ -459,6 +470,7 @@ private fun MessagesViewContent(
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
onMessageLongClick: (TimelineItem.Event) -> Unit,
onGalleryItemClick: ((TimelineItem.Event, Int) -> Unit),
onSendLocationClick: () -> Unit,
onCreatePollClick: () -> Unit,
onViewAllPinnedMessagesClick: () -> Unit,
@@ -469,9 +481,9 @@ private fun MessagesViewContent(
) {
Box(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding()
.imePadding(),
.fillMaxSize()
.navigationBarsPadding()
.imePadding(),
) {
AttachmentsBottomSheet(
state = state.composerState,
@@ -510,6 +522,7 @@ private fun MessagesViewContent(
onUserDataClick = onUserDataClick,
onLinkClick = { link -> onLinkClick(link, false) },
onContentClick = onContentClick,
onGalleryItemClick = onGalleryItemClick,
onMessageLongClick = onMessageLongClick,
onSwipeToReply = onSwipeToReply,
onReactionClick = onReactionClick,
@@ -598,9 +611,9 @@ private fun MessagesViewComposerBottomSheetContents(
private fun CantSendMessageBanner() {
Row(
modifier = Modifier
.fillMaxWidth()
.background(ElementTheme.colors.bgSubtleSecondary)
.padding(16.dp),
.fillMaxWidth()
.background(ElementTheme.colors.bgSubtleSecondary)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
@@ -637,6 +650,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
onBackClick = {},
onRoomDetailsClick = {},
onEventContentClick = { _, _ -> false },
onGalleryEventItemClick = { _, _, _ -> false },
onUserDataClick = {},
onLinkClick = { _, _ -> },
onSendLocationClick = {},
@@ -692,6 +706,7 @@ internal fun MessagesViewA11yPreview() = ElementPreview {
onBackClick = {},
onRoomDetailsClick = {},
onEventContentClick = { _, _ -> false },
onGalleryEventItemClick = { _, _, _ -> false },
onUserDataClick = {},
onLinkClick = { _, _ -> },
onSendLocationClick = {},
@@ -27,8 +27,10 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
@@ -37,6 +39,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.canBeCopied
import io.element.android.features.messages.impl.timeline.model.event.canBeForwarded
import io.element.android.features.messages.impl.timeline.model.event.canReact
import io.element.android.features.messages.impl.timeline.model.event.captionOrNull
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
@@ -198,9 +201,12 @@ class DefaultActionListPresenter(
add(TimelineItemAction.Forward)
}
if (timelineItem.isEditable && usersEventPermissions.canSendMessage) {
if (timelineItem.content is TimelineItemEventContentWithAttachment) {
if (timelineItem.content is TimelineItemEventContentWithAttachment ||
timelineItem.content is TimelineItemGalleryContent ||
timelineItem.content is TimelineItemAttachmentsContent) {
// Caption
if (timelineItem.content.caption == null) {
val caption = timelineItem.content.captionOrNull()
if (caption == null) {
add(TimelineItemAction.AddCaption)
} else {
add(TimelineItemAction.EditCaption)
@@ -225,7 +231,7 @@ class DefaultActionListPresenter(
}
if (timelineItem.content.canBeCopied()) {
add(TimelineItemAction.CopyText)
} else if ((timelineItem.content as? TimelineItemEventContentWithAttachment)?.caption.isNullOrBlank().not()) {
} else if (timelineItem.content.captionOrNull().isNullOrBlank().not()) {
add(TimelineItemAction.CopyCaption)
}
if (timelineItem.isRemote) {
@@ -63,9 +63,11 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser
import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction
import io.element.android.features.messages.impl.timeline.components.MessageShieldView
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@@ -299,6 +301,12 @@ private fun MessageSummary(
is TimelineItemImageContent -> {
content = { ContentForBody(event.content.bestDescription) }
}
is TimelineItemGalleryContent -> {
content = { ContentForBody(event.content.body) }
}
is TimelineItemAttachmentsContent -> {
content = { ContentForBody(event.content.body) }
}
is TimelineItemStickerContent -> {
content = { ContentForBody(event.content.bestDescription) }
}
@@ -23,4 +23,5 @@ sealed interface AttachmentsPreviewEvent {
data object ResetImageEdits : AttachmentsPreviewEvent
data class UpdateImageCropRect(val cropRect: NormalizedCropRect) : AttachmentsPreviewEvent
data object ClearImageEditError : AttachmentsPreviewEvent
data class SetCurrentCarouselIndex(val index: Int) : AttachmentsPreviewEvent
}
@@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
import kotlinx.collections.immutable.ImmutableList
@ContributesNode(RoomScope::class)
@AssistedInject
@@ -42,7 +43,7 @@ class AttachmentsPreviewNode(
private val enterpriseService: EnterpriseService,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val attachment: Attachment,
val attachments: ImmutableList<Attachment>,
val timelineMode: Timeline.Mode,
val inReplyToEventId: EventId?,
) : NodeInputs
@@ -54,7 +55,7 @@ class AttachmentsPreviewNode(
}
private val presenter = presenterFactory.create(
attachment = inputs.attachment,
attachments = inputs.attachments,
timelineMode = inputs.timelineMode,
onDoneListener = onDoneListener,
inReplyToEventId = inputs.inReplyToEventId,
@@ -11,7 +11,9 @@ package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -48,10 +50,11 @@ import io.element.android.libraries.mediaupload.api.allFiles
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
@@ -59,7 +62,7 @@ import java.io.File
@AssistedInject
class AttachmentsPreviewPresenter(
@Assisted private val attachment: Attachment,
@Assisted private val attachments: ImmutableList<Attachment>,
@Assisted private val onDoneListener: OnDoneListener,
@Assisted private val timelineMode: Timeline.Mode,
@Assisted private val inReplyToEventId: EventId?,
@@ -76,13 +79,18 @@ class AttachmentsPreviewPresenter(
@AssistedFactory
interface Factory {
fun create(
attachment: Attachment,
attachments: ImmutableList<Attachment>,
timelineMode: Timeline.Mode,
onDoneListener: OnDoneListener,
inReplyToEventId: EventId?,
): AttachmentsPreviewPresenter
}
data class AttachmentAndEdits(
val attachment: Attachment,
val edits: AttachmentImageEdits,
)
private val mediaSender = mediaSenderFactory.create(timelineMode)
@Composable
@@ -92,14 +100,11 @@ class AttachmentsPreviewPresenter(
val sendActionState = remember {
mutableStateOf<SendActionState>(SendActionState.Idle)
}
val originalLocalMedia = remember { (attachment as Attachment.Media).localMedia }
var currentAttachment by remember { mutableStateOf(attachment) }
var canEditImage by remember { mutableStateOf(originalLocalMedia.info.canEditImage()) }
var canEditImage by remember { mutableStateOf(false) }
var imageEditorState by remember { mutableStateOf<AttachmentImageEditorState?>(null) }
var appliedImageEdits by remember { mutableStateOf(AttachmentImageEdits()) }
var isApplyingImageEdits by remember { mutableStateOf(false) }
var displayImageEditError by remember { mutableStateOf(false) }
var editedTempFile by remember { mutableStateOf<File?>(null) }
var editedTempFiles by remember { mutableStateOf<Map<Int, File>>(emptyMap()) }
val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false)
val textEditorState by rememberUpdatedState(
@@ -108,76 +113,115 @@ class AttachmentsPreviewPresenter(
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
var preprocessMediaJob by remember { mutableStateOf<Job?>(null) }
var currentIndex by remember { mutableIntStateOf(0) }
val mediaAttachment = currentAttachment as Attachment.Media
val mediaOptimizationSelectorPresenter = remember {
mediaOptimizationSelectorPresenterFactory.create(
localMedia = mediaAttachment.localMedia,
sendAsFile = mediaAttachment.sendAsFile,
var attachmentsAndEdits by remember {
mutableStateOf(
attachments.map {
AttachmentAndEdits(it, AttachmentImageEdits())
}
)
}
val mediaOptimizationSelectorState by rememberUpdatedState(mediaOptimizationSelectorPresenter.present())
val editedAttachments by remember {
derivedStateOf {
attachmentsAndEdits.map { it.attachment }.toImmutableList()
}
}
var preprocessMediaJob by remember { mutableStateOf<Job?>(null) }
val mediaOptimizationSelectorPresenters = remember {
attachments
.filterIsInstance<Attachment.Media>()
.mapIndexed { index, attachment ->
mediaOptimizationSelectorPresenterFactory.create(
index = index,
localMedia = attachment.localMedia,
sendAsFile = attachment.sendAsFile,
)
}
}
val mediaOptimizationSelectorStates by rememberUpdatedState(
mediaOptimizationSelectorPresenters.map {
it.present()
}.toImmutableList()
)
val observableSendState = snapshotFlow { sendActionState.value }
var displayFileTooLargeError by remember { mutableStateOf(false) }
LaunchedEffect(
mediaOptimizationSelectorState.displayMediaSelectorViews,
mediaOptimizationSelectorState.videoSizeEstimations,
currentAttachment,
mediaOptimizationSelectorStates,
imageEditorState,
isApplyingImageEdits,
editedAttachments,
) {
if (mediaOptimizationSelectorStates.any { it.displayMediaSelectorViews == true } ||
imageEditorState != null ||
isApplyingImageEdits
) {
// If any of the media optimization selectors are displayed, we don't want to pre-process the media yet
return@LaunchedEffect
}
// If the media optimization selector is not displayed, we can pre-process the media
// to prepare it for sending. This is done to avoid blocking the UI thread when the
// user clicks on the send button.
@Suppress("ComplexCondition")
if (mediaOptimizationSelectorState.displayMediaSelectorViews == false &&
preprocessMediaJob == null &&
imageEditorState == null &&
!isApplyingImageEdits) {
if (mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo() && mediaOptimizationSelectorState.videoSizeEstimations.dataOrNull() == null) {
Timber.d("Waiting for video size estimations to be able to select the best video compression preset before pre-processing the media")
return@LaunchedEffect
}
val config = getAutoPreprocessMediaOptimizationConfig(
mediaAttachment = mediaAttachment,
val configs = mediaOptimizationSelectorStates.mapIndexed { index, mediaOptimizationSelectorState ->
getAutoPreprocessMediaOptimizationConfig(
mediaAttachment = editedAttachments[index] as Attachment.Media,
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
) ?: return@LaunchedEffect
preprocessMediaJob = coroutineScope.preProcessAttachment(
attachment = currentAttachment,
mediaOptimizationConfig = config,
)
}
preprocessMediaJob?.cancel()
preprocessMediaJob = coroutineScope.launch(dispatchers.io) {
preProcessAttachments(
attachments = editedAttachments,
mediaOptimizationConfigs = configs,
displayProgress = false,
sendActionState = sendActionState,
)
}
}
LaunchedEffect(originalLocalMedia) {
canEditImage = originalLocalMedia.info.canEditImage() || attachmentImageEditor.canEdit(originalLocalMedia)
LaunchedEffect(currentIndex) {
val currentMedia = (attachments.getOrNull(currentIndex) as? Attachment.Media)?.localMedia
if (currentMedia != null) {
canEditImage = currentMedia.info.canEditImage() || attachmentImageEditor.canEdit(currentMedia)
}
}
val maxUploadSize = mediaOptimizationSelectorState.maxUploadSize.dataOrNull()
val maxUploadSize = mediaOptimizationSelectorStates.firstNotNullOfOrNull {
it.maxUploadSize.dataOrNull()
}
LaunchedEffect(maxUploadSize) {
// Check file upload size if the media won't be processed for upload
val isImageFile = mediaAttachment.localMedia.info.isImageAttachment()
val isVideoFile = mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo()
if (maxUploadSize != null && !(isImageFile || isVideoFile)) {
if (maxUploadSize != null) {
// If file size is not known, we're permissive and allow sending. The SDK will cancel the upload if needed.
val fileSize = mediaAttachment.localMedia.info.fileSize ?: 0L
if (maxUploadSize < fileSize) {
displayFileTooLargeError = true
displayFileTooLargeError = attachments.any { attachment ->
when (attachment) {
is Attachment.Media -> {
val isImageFile = attachment.localMedia.info.isImageAttachment()
val isVideoFile = attachment.localMedia.info.mimeType.isMimeTypeVideo()
if (isImageFile || isVideoFile) {
false
} else {
val fileSize = attachment.localMedia.info.fileSize ?: 0L
maxUploadSize < fileSize
}
}
}
}
}
}
val videoSizeEstimations = mediaOptimizationSelectorState.videoSizeEstimations.dataOrNull()
LaunchedEffect(videoSizeEstimations) {
if (videoSizeEstimations != null) {
// Check if the video size estimations are too large for the max upload size
displayFileTooLargeError = videoSizeEstimations.none { it.canUpload }
mediaOptimizationSelectorStates.forEach { mediaOptimizationSelectorState ->
val videoSizeEstimations = mediaOptimizationSelectorState.videoSizeEstimations.dataOrNull()
LaunchedEffect(videoSizeEstimations) {
if (videoSizeEstimations != null) {
// Check if the video size estimations are too large for the max upload size
displayFileTooLargeError = videoSizeEstimations.none { it.canUpload }
}
}
}
@@ -185,19 +229,23 @@ class AttachmentsPreviewPresenter(
when (event) {
is AttachmentsPreviewEvent.SendAttachment -> {
ongoingSendAttachmentJob.value = coroutineScope.launch {
// If the media optimization selector is displayed, we need to wait for the user to select the options
// before we can pre-process the media.
if (mediaOptimizationSelectorState.displayMediaSelectorViews == true) {
val config = MediaOptimizationConfig(
compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled == true,
videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: VideoCompressionPreset.STANDARD,
)
preprocessMediaJob = preProcessAttachment(
attachment = currentAttachment,
mediaOptimizationConfig = config,
displayProgress = true,
sendActionState = sendActionState,
)
if (preprocessMediaJob?.isActive != true && sendActionState.value !is SendActionState.Sending.ReadyToUpload) {
val configs = mediaOptimizationSelectorStates.map {
MediaOptimizationConfig(
compressImages = it.isImageOptimizationEnabled
?: mediaOptimizationConfigProvider.get().compressImages,
videoCompressionPreset = it.selectedVideoPreset
?: mediaOptimizationConfigProvider.get().videoCompressionPreset,
)
}
preprocessMediaJob = coroutineScope.launch(dispatchers.io) {
preProcessAttachments(
attachments = editedAttachments,
mediaOptimizationConfigs = configs,
displayProgress = true,
sendActionState = sendActionState,
)
}
}
// If the processing was hidden before, make it visible now
@@ -206,35 +254,27 @@ class AttachmentsPreviewPresenter(
}
// Wait until the media is ready to be uploaded
val mediaUploadInfo = observableSendState.firstInstanceOf<SendActionState.Sending.ReadyToUpload>().mediaInfo
val allMediaUploadInfos = observableSendState.firstInstanceOf<SendActionState.Sending.ReadyToUpload>().mediaInfos
// Pre-processing is done, send the attachment
val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder)
.takeIf { it.isNotEmpty() }
val editedTempFileToDelete = editedTempFile
editedTempFile = null
// If we're supposed to send the media as a background job, we can dismiss this screen already
if (coroutineContext.isActive) {
onDoneListener()
}
val editedTempFilesToDelete = editedTempFiles
editedTempFiles = emptyMap()
// Send the media using the session coroutine scope so it doesn't matter if this screen or the chat one are closed
sessionCoroutineScope.launch(dispatchers.io) {
try {
sendPreProcessedMedia(
mediaUploadInfo = mediaUploadInfo,
caption = caption,
sendActionState = sendActionState,
dismissAfterSend = false,
inReplyToEventId = inReplyToEventId,
)
} finally {
editedTempFileToDelete?.safeDelete()
// Clean up the pre-processed media after it's been sent
mediaSender.cleanUp()
}
sendMedia(
mediaUploadInfos = allMediaUploadInfos,
caption = caption,
sendActionState = sendActionState,
inReplyToEventId = inReplyToEventId,
)
// Clean up the pre-processed media after it's been sent
mediaSender.cleanUp()
editedTempFilesToDelete.values.forEach { it.safeDelete() }
}
}
}
@@ -251,7 +291,11 @@ class AttachmentsPreviewPresenter(
ongoingSendAttachmentJob.value?.cancel()
// Dismiss the screen
dismiss(sendActionState, editedTempFile)
dismiss(
attachments = editedAttachments,
sendActionState = sendActionState,
editedTempFiles = editedTempFiles,
)
}
AttachmentsPreviewEvent.CancelAndClearSendState -> {
// Cancel media sending
@@ -260,22 +304,23 @@ class AttachmentsPreviewPresenter(
ongoingSendAttachmentJob.value = null
}
val mediaUploadInfo = sendActionState.value.mediaUploadInfo()
sendActionState.value = if (mediaUploadInfo != null) {
SendActionState.Sending.ReadyToUpload(mediaUploadInfo)
val mediaUploadInfoList = sendActionState.value.mediaUploadInfoList()
sendActionState.value = if (mediaUploadInfoList != null) {
SendActionState.Sending.ReadyToUpload(mediaUploadInfoList)
} else {
SendActionState.Idle
}
}
AttachmentsPreviewEvent.OpenImageEditor -> {
val resolvedCanEditImage = canEditImage || originalLocalMedia.info.canEditImage()
val currentLocalMedia = (attachments.getOrNull(currentIndex) as? Attachment.Media)?.localMedia ?: return
val resolvedCanEditImage = canEditImage || currentLocalMedia.info.canEditImage()
if (resolvedCanEditImage) {
preprocessMediaJob?.cancel()
preprocessMediaJob = null
resetPreparedMedia(sendActionState)
imageEditorState = AttachmentImageEditorState(
localMedia = originalLocalMedia,
edits = appliedImageEdits,
localMedia = currentLocalMedia,
edits = attachmentsAndEdits.get(currentIndex).edits,
previewDebug = false,
)
}
@@ -315,10 +360,15 @@ class AttachmentsPreviewPresenter(
AttachmentsPreviewEvent.ApplyImageEdits -> {
val pendingState = imageEditorState ?: return
if (!pendingState.edits.hasChanges) {
editedTempFile?.safeDelete()
editedTempFile = null
appliedImageEdits = pendingState.edits
currentAttachment = Attachment.Media(originalLocalMedia)
editedTempFiles[currentIndex]?.safeDelete()
editedTempFiles = editedTempFiles - currentIndex
val currentAttachment = attachmentsAndEdits[currentIndex].attachment
attachmentsAndEdits = attachmentsAndEdits.toMutableList().also {
it[currentIndex] = AttachmentAndEdits(
currentAttachment,
pendingState.edits,
)
}.toImmutableList()
imageEditorState = null
resetPreparedMedia(sendActionState)
return
@@ -328,16 +378,21 @@ class AttachmentsPreviewPresenter(
coroutineScope.launch {
val result = withContext(dispatchers.io) {
attachmentImageEditor.exportEdits(
localMedia = originalLocalMedia,
localMedia = pendingState.localMedia,
edits = pendingState.edits,
)
}
result.fold(
onSuccess = { editedMedia ->
editedTempFile?.safeDelete()
editedTempFile = editedMedia.file
appliedImageEdits = pendingState.edits
currentAttachment = Attachment.Media(editedMedia.localMedia)
editedTempFiles[currentIndex]?.safeDelete()
editedTempFiles = editedTempFiles + (currentIndex to editedMedia.file)
val currentAttachment = Attachment.Media(editedMedia.localMedia)
attachmentsAndEdits = attachmentsAndEdits.toMutableList().also {
it[currentIndex] = AttachmentAndEdits(
currentAttachment,
pendingState.edits,
)
}.toImmutableList()
imageEditorState = null
resetPreparedMedia(sendActionState)
},
@@ -352,19 +407,23 @@ class AttachmentsPreviewPresenter(
AttachmentsPreviewEvent.ClearImageEditError -> {
displayImageEditError = false
}
is AttachmentsPreviewEvent.SetCurrentCarouselIndex -> {
currentIndex = event.index
}
}
}
return AttachmentsPreviewState(
attachment = currentAttachment,
attachments = editedAttachments,
imageEditorState = imageEditorState,
canEditImage = canEditImage,
isApplyingImageEdits = isApplyingImageEdits,
displayImageEditError = displayImageEditError,
sendActionState = sendActionState.value,
textEditorState = textEditorState,
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
mediaOptimizationSelectorState = mediaOptimizationSelectorStates[currentIndex],
displayFileTooLargeError = displayFileTooLargeError,
currentIndex = currentIndex,
eventSink = ::handleEvent,
)
}
@@ -372,7 +431,7 @@ class AttachmentsPreviewPresenter(
private suspend fun getAutoPreprocessMediaOptimizationConfig(
mediaAttachment: Attachment.Media,
mediaOptimizationSelectorState: MediaOptimizationSelectorState,
): MediaOptimizationConfig? {
): MediaOptimizationConfig {
return if (mediaAttachment.sendAsFile) {
// If we're sending the media as a file, we can skip image compression and we should select the highest video compression preset that still fits
// the upload limit (if the estimations are available)
@@ -386,71 +445,66 @@ class AttachmentsPreviewPresenter(
videoCompressionPreset = videoCompressionPreset,
)
} else {
// Otherwise, we just rely on the user preferences for media optimization
mediaOptimizationConfigProvider.get()
MediaOptimizationConfig(
compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled
?: mediaOptimizationConfigProvider.get().compressImages,
videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset
?: mediaOptimizationConfigProvider.get().videoCompressionPreset,
)
}
}
private fun CoroutineScope.preProcessAttachment(
attachment: Attachment,
mediaOptimizationConfig: MediaOptimizationConfig,
displayProgress: Boolean,
sendActionState: MutableState<SendActionState>,
) = launch(dispatchers.io) {
when (attachment) {
is Attachment.Media -> {
preProcessMedia(
mediaAttachment = attachment,
mediaOptimizationConfig = mediaOptimizationConfig,
displayProgress = displayProgress,
sendActionState = sendActionState,
)
}
}
}
private suspend fun preProcessMedia(
mediaAttachment: Attachment.Media,
mediaOptimizationConfig: MediaOptimizationConfig,
private suspend fun preProcessAttachments(
attachments: List<Attachment>,
mediaOptimizationConfigs: List<MediaOptimizationConfig>,
displayProgress: Boolean,
sendActionState: MutableState<SendActionState>,
) {
sendActionState.value = SendActionState.Sending.Processing(displayProgress = displayProgress)
mediaSender.preProcessMedia(
uri = mediaAttachment.localMedia.uri,
mimeType = mediaAttachment.localMedia.info.mimeType,
mediaOptimizationConfig = mediaOptimizationConfig,
).fold(
onSuccess = { mediaUploadInfo ->
Timber.d("Media ${mediaUploadInfo.file.path.orEmpty().hash()} finished processing, it's now ready to upload")
sendActionState.value = SendActionState.Sending.ReadyToUpload(mediaUploadInfo)
},
onFailure = {
Timber.e(it, "Failed to pre-process media")
if (it is CancellationException) {
throw it
} else {
sendActionState.value = SendActionState.Failure(it, null)
}
}
)
}
private fun dismiss(
sendActionState: MutableState<SendActionState>,
editedTempFile: File?,
) {
// Delete the temporary file
when (attachment) {
is Attachment.Media -> {
temporaryUriDeleter.delete(attachment.localMedia.uri)
sendActionState.value.mediaUploadInfo()?.let { data ->
cleanUp(data)
val mediaUploadInfos = mutableListOf<MediaUploadInfo>()
attachments.forEachIndexed { index, attachment ->
when (attachment) {
is Attachment.Media -> {
mediaSender.preProcessMedia(
uri = attachment.localMedia.uri,
mimeType = attachment.localMedia.info.mimeType,
mediaOptimizationConfig = mediaOptimizationConfigs[index],
).fold(
onSuccess = { mediaUploadInfo ->
Timber.d("Media ${mediaUploadInfo.file.path.orEmpty().hash()} finished processing")
mediaUploadInfos.add(mediaUploadInfo)
},
onFailure = {
Timber.e(it, "Failed to pre-process media")
if (it is CancellationException) {
throw it
} else {
sendActionState.value = SendActionState.Failure(it, emptyList())
return
}
}
)
}
}
}
editedTempFile?.safeDelete()
// Reset the sendActionState to ensure that dialog is closed before the screen
sendActionState.value = SendActionState.Sending.ReadyToUpload(mediaUploadInfos)
}
private fun dismiss(
attachments: List<Attachment>,
sendActionState: MutableState<SendActionState>,
editedTempFiles: Map<Int, File> = emptyMap(),
) {
for (attachment in attachments) {
when (attachment) {
is Attachment.Media -> {
temporaryUriDeleter.delete(attachment.localMedia.uri)
}
}
}
val uploadInfos = (sendActionState.value as? SendActionState.Sending.ReadyToUpload)?.mediaInfos
uploadInfos?.forEach { cleanUp(it) }
editedTempFiles.values.forEach { it.safeDelete() }
sendActionState.value = SendActionState.Done
onDoneListener()
}
@@ -464,41 +518,45 @@ class AttachmentsPreviewPresenter(
}
private fun resetPreparedMedia(sendActionState: MutableState<SendActionState>) {
sendActionState.value.mediaUploadInfo()?.let(::cleanUp)
sendActionState.value.mediaUploadInfoList()?.forEach(::cleanUp)
mediaSender.cleanUp()
sendActionState.value = SendActionState.Idle
}
private suspend fun sendPreProcessedMedia(
mediaUploadInfo: MediaUploadInfo,
private suspend fun sendMedia(
mediaUploadInfos: List<MediaUploadInfo>,
caption: String?,
sendActionState: MutableState<SendActionState>,
dismissAfterSend: Boolean,
inReplyToEventId: EventId?,
) = runCatchingExceptions {
sendActionState.value = SendActionState.Sending.Uploading(mediaUploadInfo)
mediaSender.sendPreProcessedMedia(
mediaUploadInfo = mediaUploadInfo,
caption = caption,
formattedCaption = null,
inReplyToEventId = inReplyToEventId,
).getOrThrow()
if (mediaUploadInfos.size == 1) {
sendActionState.value = SendActionState.Sending.Uploading(mediaUploadInfos)
mediaSender.sendPreProcessedMedia(
mediaUploadInfo = mediaUploadInfos.first(),
caption = caption,
formattedCaption = null,
inReplyToEventId = inReplyToEventId,
).getOrThrow()
} else {
mediaSender.sendGallery(
mediaUploadInfos = mediaUploadInfos,
caption = caption,
formattedCaption = null,
inReplyToEventId = inReplyToEventId,
).getOrThrow()
}
}.fold(
onSuccess = {
cleanUp(mediaUploadInfo)
// Reset the sendActionState to ensure that dialog is closed before the screen
mediaUploadInfos.forEach { cleanUp(it) }
sendActionState.value = SendActionState.Done
if (dismissAfterSend) {
onDoneListener()
}
onDoneListener()
},
onFailure = { error ->
Timber.e(error, "Failed to send attachment")
if (error is CancellationException) {
throw error
} else {
sendActionState.value = SendActionState.Failure(error, mediaUploadInfo)
sendActionState.value = SendActionState.Failure(error, mediaUploadInfos)
}
}
)
@@ -14,9 +14,10 @@ import io.element.android.features.messages.impl.attachments.preview.imageeditor
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.textcomposer.model.TextEditorState
import kotlinx.collections.immutable.ImmutableList
data class AttachmentsPreviewState(
val attachment: Attachment,
val attachments: ImmutableList<Attachment>,
val imageEditorState: AttachmentImageEditorState?,
val canEditImage: Boolean,
val isApplyingImageEdits: Boolean,
@@ -25,8 +26,12 @@ data class AttachmentsPreviewState(
val textEditorState: TextEditorState,
val mediaOptimizationSelectorState: MediaOptimizationSelectorState,
val displayFileTooLargeError: Boolean,
val currentIndex: Int,
val eventSink: (AttachmentsPreviewEvent) -> Unit,
)
) {
val isGallery: Boolean get() = attachments.size > 1
val totalCount: Int get() = attachments.size
}
@Immutable
sealed interface SendActionState {
@@ -35,17 +40,17 @@ sealed interface SendActionState {
@Immutable
sealed interface Sending : SendActionState {
data class Processing(val displayProgress: Boolean) : Sending
data class ReadyToUpload(val mediaInfo: MediaUploadInfo) : Sending
data class Uploading(val mediaUploadInfo: MediaUploadInfo) : Sending
data class ReadyToUpload(val mediaInfos: List<MediaUploadInfo>) : Sending
data class Uploading(val mediaInfos: List<MediaUploadInfo>) : Sending
}
data class Failure(val error: Throwable, val mediaUploadInfo: MediaUploadInfo?) : SendActionState
data class Failure(val error: Throwable, val mediaInfos: List<MediaUploadInfo>) : SendActionState
data object Done : SendActionState
fun mediaUploadInfo(): MediaUploadInfo? = when (this) {
is Sending.ReadyToUpload -> mediaInfo
is Sending.Uploading -> mediaUploadInfo
is Failure -> mediaUploadInfo
fun mediaUploadInfoList(): List<MediaUploadInfo>? = when (this) {
is Sending.ReadyToUpload -> mediaInfos
is Sending.Uploading -> mediaInfos
is Failure -> mediaInfos
else -> null
}
}
@@ -41,9 +41,9 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider<Attachment
)
),
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Processing(displayProgress = true)),
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.ReadyToUpload(aMediaUploadInfo())),
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(aMediaUploadInfo())),
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"), aMediaUploadInfo())),
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.ReadyToUpload(listOf(aMediaUploadInfo()))),
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(listOf(aMediaUploadInfo()))),
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"), listOf(aMediaUploadInfo()))),
anAttachmentsPreviewState(
imageEditorState = anAttachmentImageEditorState(),
),
@@ -72,9 +72,12 @@ fun anAttachmentsPreviewState(
imageEditorState: AttachmentImageEditorState? = null,
mediaOptimizationSelectorState: MediaOptimizationSelectorState = aMediaOptimisationSelectorState(),
displayFileTooLargeError: Boolean = false,
currentIndex: Int = 0,
) = AttachmentsPreviewState(
attachment = Attachment.Media(
localMedia = LocalMedia("file://path".toUri(), mediaInfo),
attachments = persistentListOf(
Attachment.Media(
localMedia = LocalMedia("file://path".toUri(), mediaInfo),
),
),
imageEditorState = imageEditorState,
canEditImage = true,
@@ -84,6 +87,37 @@ fun anAttachmentsPreviewState(
textEditorState = textEditorState,
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
displayFileTooLargeError = displayFileTooLargeError,
currentIndex = currentIndex,
eventSink = {}
)
fun anAttachmentsPreviewGalleryState(
mediaInfo: MediaInfo = anImageMediaInfo(),
textEditorState: TextEditorState = aTextEditorStateMarkdown(),
sendActionState: SendActionState = SendActionState.Idle,
mediaOptimizationSelectorState: MediaOptimizationSelectorState = aMediaOptimisationSelectorState(),
currentIndex: Int = 0,
) = AttachmentsPreviewState(
attachments = persistentListOf(
Attachment.Media(
localMedia = LocalMedia("file://path1".toUri(), mediaInfo),
),
Attachment.Media(
localMedia = LocalMedia("file://path2".toUri(), mediaInfo),
),
Attachment.Media(
localMedia = LocalMedia("file://path3".toUri(), mediaInfo),
),
),
imageEditorState = null,
canEditImage = false,
isApplyingImageEdits = false,
displayImageEditError = false,
sendActionState = sendActionState,
textEditorState = textEditorState,
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
displayFileTooLargeError = false,
currentIndex = currentIndex,
eventSink = {}
)
@@ -112,6 +146,7 @@ fun aMediaOptimisationSelectorState(
displayMediaSelectorViews: Boolean = true,
displayVideoPresetSelectorDialog: Boolean = false,
) = MediaOptimizationSelectorState(
index = 0,
maxUploadSize = AsyncData.Success(maxUploadSize),
videoSizeEstimations = videoSizeEstimations,
isImageOptimizationEnabled = isImageOptimizationEnabled,
@@ -9,11 +9,15 @@
package io.element.android.features.messages.impl.attachments.preview
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -21,15 +25,19 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
@@ -62,11 +70,11 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Switch
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.designsystem.theme.floatingDateBadgeBackground
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.textcomposer.TextComposer
@@ -77,6 +85,9 @@ import io.element.android.libraries.ui.utils.formatter.rememberFileSizeFormatter
import io.element.android.wysiwyg.display.TextDisplay
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlin.time.Duration.Companion.milliseconds
/**
* Ref: https://www.figma.com/design/zftpgS6LjiczobJZ1GUNpt/Updates-to-Media---File-Upload?node-id=51-3514
@@ -252,6 +263,7 @@ private fun AttachmentSendStateView(
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
private fun AttachmentPreviewContent(
state: AttachmentsPreviewState,
@@ -267,15 +279,74 @@ private fun AttachmentPreviewContent(
Box(
modifier = Modifier
.weight(1f),
contentAlignment = Alignment.Center
contentAlignment = Alignment.Center,
) {
when (val attachment = state.attachment) {
is Attachment.Media -> {
localMediaRenderer.Render(attachment.localMedia)
if (state.isGallery) {
val pagerState = rememberPagerState(
initialPage = state.currentIndex,
pageCount = { state.attachments.size },
)
var isPillVisible by remember { mutableStateOf(true) }
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
beyondViewportPageCount = 1,
contentPadding = PaddingValues(horizontal = 20.dp),
pageSpacing = 10.dp,
) { page ->
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
val attachment = state.attachments[page]
when (attachment) {
is Attachment.Media -> {
localMediaRenderer.Render(attachment.localMedia)
}
}
}
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.isScrollInProgress }
.collectLatest { isScrolling ->
if (isScrolling) {
isPillVisible = true
} else {
delay(2000.milliseconds)
isPillVisible = false
}
}
}
androidx.compose.animation.AnimatedVisibility(
visible = isPillVisible,
enter = fadeIn(animationSpec = tween(150)),
exit = fadeOut(animationSpec = tween(300)),
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 8.dp),
) {
GalleryCarouselPill(
currentIndex = pagerState.currentPage + 1,
totalCount = state.totalCount,
)
}
LaunchedEffect(pagerState.currentPage) {
state.eventSink(AttachmentsPreviewEvent.SetCurrentCarouselIndex(pagerState.currentPage))
}
} else {
val firstAttachment = state.attachments.first()
when (firstAttachment) {
is Attachment.Media -> {
localMediaRenderer.Render(firstAttachment.localMedia)
}
}
}
}
val mediaInfo = (state.attachment as? Attachment.Media)?.localMedia?.info
val mediaInfo = (state.attachments[state.currentIndex] as? Attachment.Media)?.localMedia?.info
if (mediaInfo?.isImageAttachment() == true) {
ImageOptimizationSelector(state.mediaOptimizationSelectorState)
} else if (mediaInfo?.mimeType?.isMimeTypeVideo() == true) {
@@ -485,16 +556,16 @@ private fun AttachmentsPreviewBottomActions(
internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = ElementPreviewDark {
AttachmentsPreviewView(
state = state,
localMediaRenderer = object : LocalMediaRenderer {
@Composable
override fun Render(localMedia: LocalMedia) {
Image(
painter = painterResource(id = CommonDrawables.sample_background),
modifier = Modifier.fillMaxSize(),
contentDescription = null,
)
}
}
localMediaRenderer = SampleMediaRenderer(),
)
}
@Preview
@Composable
internal fun AttachmentsPreviewGalleryViewPreview() = ElementPreviewDark {
AttachmentsPreviewView(
state = anAttachmentsPreviewGalleryState(),
localMediaRenderer = SampleMediaRenderer(),
)
}
@@ -537,3 +608,24 @@ fun VideoCompressionPreset.subtitle(): String {
}
)
}
@Composable
internal fun GalleryCarouselPill(
currentIndex: Int,
totalCount: Int,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(16.dp),
color = ElementTheme.colors.floatingDateBadgeBackground,
shadowElevation = 4.dp,
) {
Text(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
text = stringResource(R.string.screen_media_upload_preview_item_count, currentIndex, totalCount),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
)
}
}
@@ -0,0 +1,32 @@
/*
* 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.features.messages.impl.attachments.preview
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
/**
* An implementation of [LocalMediaRenderer] that displays a sample background image.
* To be used for Previews only.
*/
internal class SampleMediaRenderer : LocalMediaRenderer {
@Composable
override fun Render(localMedia: LocalMedia) {
Image(
painter = painterResource(id = CommonDrawables.sample_background),
modifier = Modifier.fillMaxSize(),
contentDescription = null,
)
}
}
@@ -36,6 +36,7 @@ import kotlin.math.roundToLong
@AssistedInject
class DefaultMediaOptimizationSelectorPresenter(
@Assisted private val index: Int,
@Assisted private val localMedia: LocalMedia,
@Assisted private val sendAsFile: Boolean,
private val maxUploadSizeProvider: MaxUploadSizeProvider,
@@ -48,6 +49,7 @@ class DefaultMediaOptimizationSelectorPresenter(
@AssistedFactory
interface Factory : MediaOptimizationSelectorPresenter.Factory {
override fun create(
index: Int,
localMedia: LocalMedia,
sendAsFile: Boolean,
): DefaultMediaOptimizationSelectorPresenter
@@ -183,6 +185,7 @@ class DefaultMediaOptimizationSelectorPresenter(
}
return MediaOptimizationSelectorState(
index = index,
maxUploadSize = maxUploadSize,
videoSizeEstimations = videoSizeEstimations,
isImageOptimizationEnabled = selectedImageOptimization.dataOrNull(),
@@ -14,6 +14,7 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMedia
fun interface MediaOptimizationSelectorPresenter : Presenter<MediaOptimizationSelectorState> {
interface Factory {
fun create(
index: Int,
localMedia: LocalMedia,
sendAsFile: Boolean,
): MediaOptimizationSelectorPresenter
@@ -13,6 +13,8 @@ import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import kotlinx.collections.immutable.ImmutableList
data class MediaOptimizationSelectorState(
// Adding an index helps to fix a Compose issue where the state of the wrong item is updated
val index: Int,
val maxUploadSize: AsyncData<Long>,
val videoSizeEstimations: AsyncData<ImmutableList<VideoUploadEstimation>>,
val isImageOptimizationEnabled: Boolean?,
@@ -35,6 +35,7 @@ internal fun MessagesViewWithIdentityChangePreview(
onBackClick = {},
onRoomDetailsClick = {},
onEventContentClick = { _, _ -> false },
onGalleryEventItemClick = { _, _, _ -> false },
onUserDataClick = {},
onLinkClick = { _, _ -> },
onSendLocationClick = {},
@@ -34,7 +34,6 @@ import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.location.api.LocationService
import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.Attachment.Media
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
import io.element.android.features.messages.impl.draft.ComposerDraftService
import io.element.android.features.messages.impl.messagecomposer.suggestions.RoomAliasSuggestionsDataSource
@@ -48,6 +47,8 @@ import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
@@ -133,6 +134,7 @@ class MessageComposerPresenter(
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
private val notificationConversationService: NotificationConversationService,
private val slashCommandService: SlashCommandService,
private val featureFlagService: FeatureFlagService,
) : Presenter<MessageComposerState> {
@AssistedFactory
interface Factory {
@@ -177,10 +179,19 @@ class MessageComposerPresenter(
canShareLocation.value = locationService.isServiceAvailable()
}
val isSendGalleryMessagesEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.SendGalleryMessages)
.collectAsState(initial = false)
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType ->
handlePickedMedia(uri, mimeType)
}
val filesPicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri, mimeType ->
val galleryMultiMediaPicker = mediaPickerProvider.registerGalleryMultiPicker { uris ->
handlePickedMediaList(uris)
}
val filesPicker = mediaPickerProvider.registerFileMultiPicker(AnyMimeTypes) { uris ->
handlePickedMediaList(uris, sendAsFile = true)
}
val fileSinglePicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri, mimeType ->
handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream, sendAsFile = true)
}
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
@@ -266,7 +277,7 @@ class MessageComposerPresenter(
is MessageComposerEvent.SendUri -> {
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
sessionCoroutineScope.sendAttachment(
attachment = Media(
attachment = Attachment.Media(
localMedia = localMediaFactory.createFromUri(
uri = event.uri,
mimeType = null,
@@ -289,11 +300,19 @@ class MessageComposerPresenter(
MessageComposerEvent.DismissAttachmentMenu -> showAttachmentSourcePicker = false
MessageComposerEvent.PickAttachmentSource.FromGallery -> localCoroutineScope.launch {
showAttachmentSourcePicker = false
galleryMediaPicker.launch()
if (isSendGalleryMessagesEnabled) {
galleryMultiMediaPicker.launch()
} else {
galleryMediaPicker.launch()
}
}
MessageComposerEvent.PickAttachmentSource.FromFiles -> localCoroutineScope.launch {
showAttachmentSourcePicker = false
filesPicker.launch()
if (isSendGalleryMessagesEnabled) {
filesPicker.launch()
} else {
fileSinglePicker.launch()
}
}
MessageComposerEvent.PickAttachmentSource.PhotoFromCamera -> localCoroutineScope.launch {
showAttachmentSourcePicker = false
@@ -623,6 +642,30 @@ class MessageComposerPresenter(
messageComposerContext.composerMode = MessageComposerMode.Normal
}
private fun handlePickedMediaList(
uris: List<Uri>,
sendAsFile: Boolean = false,
) {
if (uris.isEmpty()) return
if (uris.size == 1) {
handlePickedMedia(uris.first(), sendAsFile = sendAsFile)
return
}
val attachments = uris.map { uri ->
val localMedia = localMediaFactory.createFromUri(
uri = uri,
mimeType = null,
name = null,
formattedFileSize = null,
)
Attachment.Media(localMedia, sendAsFile = sendAsFile)
}.toImmutableList()
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
navigator.navigateToPreviewAttachments(attachments, inReplyToEventId)
messageComposerContext.composerMode = MessageComposerMode.Normal
}
private suspend fun sendMedia(
uri: Uri,
mimeType: String,
@@ -53,6 +53,7 @@ class PinnedMessagesListNode(
) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator {
interface Callback : Plugin {
fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean)
fun handleGalleryItemClick(event: TimelineItem.Event, galleryItemIndex: Int, canUseOverlay: Boolean)
fun navigateToRoomMemberDetails(userId: UserId)
fun viewInTimeline(eventId: EventId)
fun handlePermalinkClick(data: PermalinkData.RoomLink)
@@ -119,6 +120,9 @@ class PinnedMessagesListNode(
onEventClick = {
callback.handleEventClick(it, canUseOverlay)
},
onGalleryItemClick = { event, index ->
callback.handleGalleryItemClick(event, index, canUseOverlay)
},
onUserDataClick = { callback.navigateToRoomMemberDetails(it.userId) },
onLinkClick = { link -> onLinkClick(context, link.url) },
onLinkLongClick = {
@@ -60,6 +60,7 @@ fun PinnedMessagesListView(
state: PinnedMessagesListState,
onBackClick: () -> Unit,
onEventClick: (event: TimelineItem.Event) -> Unit,
onGalleryItemClick: (event: TimelineItem.Event, index: Int) -> Unit,
onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
@@ -81,6 +82,7 @@ fun PinnedMessagesListView(
PinnedMessagesListContent(
state = state,
onEventClick = onEventClick,
onGalleryItemClick = onGalleryItemClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
@@ -111,6 +113,7 @@ private fun PinnedMessagesListTopBar(
private fun PinnedMessagesListContent(
state: PinnedMessagesListState,
onEventClick: (event: TimelineItem.Event) -> Unit,
onGalleryItemClick: (event: TimelineItem.Event, index: Int) -> Unit,
onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
@@ -131,6 +134,7 @@ private fun PinnedMessagesListContent(
state = state,
displayThreadSummaries = state.displayThreadSummaries,
onEventClick = onEventClick,
onGalleryItemClick = onGalleryItemClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
@@ -169,6 +173,7 @@ private fun PinnedMessagesListLoaded(
state: PinnedMessagesListState.Filled,
displayThreadSummaries: Boolean,
onEventClick: (event: TimelineItem.Event) -> Unit,
onGalleryItemClick: (event: TimelineItem.Event, index: Int) -> Unit,
onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
@@ -226,6 +231,7 @@ private fun PinnedMessagesListLoaded(
},
onLinkLongClick = onLinkLongClick,
onContentClick = onEventClick,
onGalleryItemClick = onGalleryItemClick,
onLongClick = ::onMessageLongClick,
displayThreadSummaries = displayThreadSummaries,
inReplyToClick = {},
@@ -245,6 +251,7 @@ private fun PinnedMessagesListLoaded(
event = event,
timelineProtectionState = state.timelineProtectionState,
onContentClick = { onEventClick(event) },
onGalleryItemClick = { index -> onGalleryItemClick(event, index) },
onLongClick = { onMessageLongClick(event) },
onLinkClick = { link ->
state.linkState.eventSink(LinkEvent.OnLinkClick(link))
@@ -268,6 +275,7 @@ private fun TimelineItemEventContentViewWrapper(
event: TimelineItem.Event,
timelineProtectionState: TimelineProtectionState,
onContentClick: () -> Unit,
onGalleryItemClick: (index: Int) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onLongClick: (() -> Unit)?,
@@ -285,6 +293,7 @@ private fun TimelineItemEventContentViewWrapper(
content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onGalleryItemClick = onGalleryItemClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
eventSink = { },
@@ -304,6 +313,7 @@ internal fun PinnedMessagesListViewPreview(@PreviewParameter(PinnedMessagesListS
state = state,
onBackClick = {},
onEventClick = { },
onGalleryItemClick = { _, _ -> },
onUserDataClick = {},
onLinkClick = {},
onLinkLongClick = {},
@@ -126,6 +126,7 @@ class ThreadedMessagesNode(
interface Callback : Plugin {
fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean
fun handleGalleryItemClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, galleryItemIndex: Int, canUseOverlay: Boolean): Boolean
fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
fun navigateToRoomMemberDetails(userId: UserId)
fun handlePermalinkClick(data: PermalinkData)
@@ -289,6 +290,20 @@ class ThreadedMessagesNode(
}
} == true
},
onGalleryEventItemClick = { isLive, event, index ->
timelineController?.let { controller ->
if (isLive) {
callback.handleGalleryItemClick(controller.mainTimelineMode(), event, index, canUseOverlay)
} else {
val detachedTimelineMode = controller.detachedTimelineMode()
if (detachedTimelineMode != null) {
callback.handleGalleryItemClick(detachedTimelineMode, event, index, canUseOverlay)
} else {
false
}
}
} == true
},
onUserDataClick = callback::navigateToRoomMemberDetails,
onLinkClick = { url, customTab ->
onLinkClick(
@@ -97,6 +97,7 @@ fun TimelineView(
onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit,
onContentClick: (TimelineItem.Event) -> Unit,
onGalleryItemClick: ((TimelineItem.Event, Int) -> Unit),
onMessageLongClick: (TimelineItem.Event) -> Unit,
onSwipeToReply: (TimelineItem.Event) -> Unit,
onReactionClick: (emoji: String, TimelineItem.Event) -> Unit,
@@ -178,6 +179,7 @@ fun TimelineView(
onLinkClick = onLinkClick,
onLinkLongClick = ::onLinkLongClick,
onContentClick = onContentClick,
onGalleryItemClick = onGalleryItemClick,
onLongClick = onMessageLongClick,
inReplyToClick = ::inReplyToClick,
onReactionClick = onReactionClick,
@@ -428,6 +430,7 @@ internal fun TimelineViewPreview(
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
onGalleryItemClick = { _, _ -> },
forceJumpToBottomVisibility = true,
)
}
@@ -49,6 +49,7 @@ internal fun TimelineViewMessageShieldPreview() = ElementPreview {
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
onGalleryItemClick = { _, _ -> },
forceJumpToBottomVisibility = true,
)
}
@@ -33,6 +33,7 @@ internal fun ATimelineItemEventRow(
isLastOutgoingMessage = isLastOutgoingMessage,
displayThreadSummaries = displayThreadSummaries,
onEventClick = {},
onGalleryItemClick = {},
onLongClick = {},
onLinkClick = {},
onLinkLongClick = {},
@@ -0,0 +1,138 @@
/*
* 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.features.messages.impl.timeline.components
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.timeline.components.event.aGalleryItem
import io.element.android.features.messages.impl.timeline.components.event.aTimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.model.event.GalleryItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem
import kotlin.time.Duration
class TimelineItemEventContentForGalleryViewProvider :
PreviewParameterProvider<TimelineItemEventContent> {
override val values: Sequence<TimelineItemEventContent>
get() = sequenceOf(
aTimelineItemGalleryContent(
caption = "My vacation photos",
items = listOf(
aGalleryItem(),
aGalleryItem(),
aGalleryItem(),
aGalleryItem(),
),
),
aTimelineItemGalleryContent(
items = listOf(
aGalleryItem(),
aGalleryItem(),
),
),
aTimelineItemGalleryContent(
caption = "Three photos",
items = listOf(
aGalleryItem(),
aGalleryItem(),
aGalleryItem(),
),
),
aTimelineItemGalleryContent(
caption = "Many photos",
items = (1..8).map { aGalleryItem() },
),
aTimelineItemGalleryContent(
caption = "Videos",
items = listOf(
aGalleryItem(
type = GalleryItem.Type.Video,
duration = Duration.parse("PT1M30S")
),
aGalleryItem(
type = GalleryItem.Type.Video,
duration = Duration.parse("PT45S")
),
aGalleryItem(),
),
),
aTimelineItemAttachmentsContent(
caption = "Documents",
attachments = listOf(
anAttachmentItem(
filename = "document.pdf",
fileExtension = "pdf",
),
anAttachmentItem(
filename = "presentation.pdf",
fileExtension = "pdf",
),
anAttachmentItem(
filename = "spreadsheet.xlsx",
fileExtension = "xlsx",
),
),
),
aTimelineItemAttachmentsContent(
caption = "Photos",
attachments = listOf(
anAttachmentItem(
filename = "photo1.jpg",
fileExtension = "jpg",
hasThumbnail = true,
),
anAttachmentItem(
filename = "photo2.jpg",
fileExtension = "jpg",
hasThumbnail = true,
),
anAttachmentItem(
filename = "photo3.jpg",
fileExtension = "jpg",
hasThumbnail = true,
),
),
),
aTimelineItemAttachmentsContent(
caption = "Videos",
attachments = listOf(
anAttachmentItem(
filename = "video1.mp4",
fileExtension = "mp4",
hasThumbnail = true,
fileSize = 150_000_000L,
formattedFileSize = "150MB",
),
anAttachmentItem(
filename = "video2.mov",
fileExtension = "mov",
hasThumbnail = true,
fileSize = 85_000_000L,
formattedFileSize = "85MB",
),
),
),
aTimelineItemAttachmentsContent(
caption = "Audio",
attachments = listOf(
anAttachmentItem(
filename = "recording.mp3",
fileExtension = "mp3",
fileSize = 4_500_000L,
formattedFileSize = "4.5MB",
),
anAttachmentItem(
filename = "voice_message.m4a",
fileExtension = "m4a",
fileSize = 1_200_000L,
formattedFileSize = "1.2MB",
),
),
),
)
}
@@ -76,6 +76,8 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemGrou
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
@@ -156,6 +158,7 @@ fun TimelineItemEventRow(
isLastOutgoingMessage: Boolean,
displayThreadSummaries: Boolean,
onEventClick: () -> Unit,
onGalleryItemClick: ((Int) -> Unit),
onLongClick: () -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
@@ -176,13 +179,14 @@ fun TimelineItemEventRow(
content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
onContentClick = onContentClick,
onGalleryItemClick = onGalleryItemClick,
onLongClick = onLongClick,
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
eventSink = eventSink,
modifier = contentModifier,
onContentLayoutChange = onContentLayoutChange
onContentLayoutChange = onContentLayoutChange,
)
},
) {
@@ -808,6 +812,8 @@ private fun MessageEventBubbleContent(
val timestampPosition = when (val content = event.content) {
is TimelineItemImageContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
is TimelineItemVideoContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
is TimelineItemGalleryContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Below
is TimelineItemAttachmentsContent -> TimestampPosition.Below
is TimelineItemStickerContent -> TimestampPosition.Overlay
is TimelineItemLocationContent -> {
val content = content.ensureActiveLiveLocation()
@@ -822,6 +828,8 @@ private fun MessageEventBubbleContent(
val paddingBehaviour = when (event.content) {
is TimelineItemImageContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
is TimelineItemVideoContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
is TimelineItemGalleryContent -> ContentPadding.CaptionedMedia
is TimelineItemAttachmentsContent -> ContentPadding.CaptionedMedia
is TimelineItemStickerContent,
is TimelineItemLocationContent -> ContentPadding.Media
else -> ContentPadding.Textual
@@ -0,0 +1,35 @@
/*
* 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.features.messages.impl.timeline.components
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@PreviewsDayNight
@Composable
internal fun TimelineItemEventRowWithGalleryPreview(
@PreviewParameter(TimelineItemEventContentForGalleryViewProvider::class) content: TimelineItemEventContent,
) = ElementPreview {
Column {
sequenceOf(false, true).forEach { isMine ->
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = isMine,
content = content,
groupPosition = TimelineItemGroupPosition.Last,
),
)
}
}
}
@@ -69,6 +69,7 @@ fun TimelineItemGroupedEventsRow(
eventSink = eventSink,
modifier = contentModifier,
onContentClick = null,
onGalleryItemClick = {},
onLongClick = null,
onContentLayoutChange = onContentLayoutChange
)
@@ -140,6 +141,7 @@ private fun TimelineItemGroupedEventsRowContent(
eventSink = eventSink,
modifier = contentModifier,
onContentClick = null,
onGalleryItemClick = {},
onLongClick = null,
onContentLayoutChange = onContentLayoutChange
)
@@ -177,6 +179,7 @@ private fun TimelineItemGroupedEventsRowContent(
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onContentClick = onClick,
onGalleryItemClick = { _, _ -> },
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,
onReactionClick = onReactionClick,
@@ -64,6 +64,7 @@ internal fun TimelineItemRow(
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onContentClick: (TimelineItem.Event) -> Unit,
onGalleryItemClick: (TimelineItem.Event, Int) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
@@ -80,12 +81,13 @@ internal fun TimelineItemRow(
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onContentClick = { onContentClick(event) },
onGalleryItemClick = { index -> onGalleryItemClick(event, index) },
onLongClick = { onLongClick(event) },
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
eventSink = eventSink,
modifier = contentModifier,
onContentLayoutChange = onContentLayoutChange
onContentLayoutChange = onContentLayoutChange,
)
},
) {
@@ -178,6 +180,7 @@ internal fun TimelineItemRow(
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
onSwipeToReply = { onSwipeToReply(timelineItem) },
onGalleryItemClick = { index -> onGalleryItemClick(timelineItem, index) },
eventSink = eventSink,
eventContentView = { contentModifier, onContentLayoutChange ->
eventContentView(timelineItem, contentModifier, onContentLayoutChange)
@@ -74,6 +74,7 @@ fun TimelineItemStateEventRow(
onShowContentClick = {},
eventSink = eventSink,
onContentClick = null,
onGalleryItemClick = {},
onLongClick = null,
modifier = Modifier.defaultTimelineContentPadding()
)
@@ -0,0 +1,76 @@
/*
* 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.features.messages.impl.timeline.components.event
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem
import io.element.android.libraries.matrix.api.media.MediaSource
class TimelineItemAttachmentsContentProvider : PreviewParameterProvider<TimelineItemAttachmentsContent> {
override val values: Sequence<TimelineItemAttachmentsContent>
get() = sequenceOf(
aTimelineItemAttachmentsContent(
body = "Files",
caption = null,
attachments = listOf(
anAttachmentItem(
filename = "document.pdf",
mimeType = "application/pdf",
fileSize = null,
formattedFileSize = "2.5 MB",
fileExtension = "PDF",
),
anAttachmentItem(
filename = "spreadsheet.xlsx",
mimeType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
fileSize = null,
formattedFileSize = "450 KB",
fileExtension = "XLSX",
),
),
),
aTimelineItemAttachmentsContent(
body = "Files",
caption = "Files mixed with media",
attachments = listOf(
anAttachmentItem(
filename = "report.pdf",
mimeType = "application/pdf",
fileSize = null,
formattedFileSize = "3.2 MB",
fileExtension = "PDF",
),
anAttachmentItem(
filename = "notes.txt",
mimeType = "text/plain",
fileSize = null,
formattedFileSize = "12 KB",
fileExtension = "TXT",
),
anAttachmentItem(
filename = "photo.jpg",
mimeType = "image/jpeg",
thumbnailSource = MediaSource(url = "thumb", json = ""),
fileSize = null,
formattedFileSize = "1.2 MB",
fileExtension = "JPG",
),
anAttachmentItem(
filename = "video.mp4",
mimeType = "video/mp4",
thumbnailSource = MediaSource(url = "thumb", json = ""),
fileSize = null,
formattedFileSize = "15 MB",
fileExtension = "MP4",
),
),
),
)
}
@@ -0,0 +1,222 @@
/*
* 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.features.messages.impl.timeline.components.event
import android.text.SpannedString
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.AttachmentItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
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.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link
@Composable
fun TimelineItemAttachmentsListView(
content: TimelineItemAttachmentsContent,
onGalleryItemClick: (Int) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
Column(
modifier = Modifier.fillMaxWidth(),
) {
content.attachments.forEachIndexed { index, attachment ->
Column {
if (index > 0) {
HorizontalDivider(
color = ElementTheme.colors.borderInteractiveSecondary,
)
}
AttachmentListItem(
attachment = attachment,
onClick = { onGalleryItemClick(index) },
)
}
}
}
if (content.showCaption) {
HorizontalDivider(
color = ElementTheme.colors.borderInteractiveSecondary,
)
Spacer(modifier = Modifier.height(8.dp))
val caption = if (LocalInspectionMode.current) {
SpannedString(content.caption)
} else {
content.formattedCaption ?: SpannedString(content.caption)
}
CompositionLocalProvider(
LocalContentColor provides ElementTheme.colors.textPrimary,
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
) {
EditorStyledText(
modifier = Modifier
.padding(horizontal = 4.dp)
.widthIn(min = 120.dp),
text = caption,
style = ElementRichTextEditorStyle.textStyle(),
onLinkClickedListener = onLinkClick,
onLinkLongClickedListener = onLinkLongClick,
releaseOnDetach = false,
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
)
}
}
}
}
@Composable
private fun AttachmentListItem(
attachment: AttachmentItem,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val iconSize = 36.dp
val thumbnailSize = 36L
val spacing = 8.dp
Row(
modifier = modifier
.clickable(onClick = onClick)
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing),
) {
Box(
modifier = Modifier
.size(iconSize)
.clip(RoundedCornerShape(4.dp))
.background(ElementTheme.colors.bgCanvasDefault),
contentAlignment = Alignment.Center,
) {
if (attachment.thumbnailSource != null) {
val isVideo = attachment.mimeType.isMimeTypeVideo()
AsyncImage(
model = MediaRequestData(
source = attachment.thumbnailSource,
kind = MediaRequestData.Kind.Thumbnail(thumbnailSize),
),
contentDescription = attachment.filename,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(iconSize)
.clip(RoundedCornerShape(4.dp)),
)
if (isVideo) {
Box(
modifier = Modifier
.size(iconSize)
.background(
color = Color.Black.copy(alpha = 0.3f),
shape = RoundedCornerShape(4.dp)
),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = CompoundIcons.VideoCallSolid(),
contentDescription = stringResource(CommonStrings.common_video),
tint = Color.White,
modifier = Modifier.size(20.dp),
)
}
}
} else {
val isImage = attachment.mimeType.isMimeTypeImage()
val isVideo = attachment.mimeType.isMimeTypeVideo()
val isAudio = attachment.mimeType.isMimeTypeAudio()
val icon = when {
isImage -> CompoundIcons.Image()
isVideo -> CompoundIcons.VideoCall()
isAudio -> CompoundIcons.Audio()
else -> CompoundIcons.Attachment()
}
Icon(
imageVector = icon,
contentDescription = null,
tint = ElementTheme.colors.iconPrimary,
modifier = Modifier.size(24.dp),
)
}
}
Column(
modifier = Modifier.weight(1f),
) {
Text(
text = attachment.filename,
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontBodyLgRegular,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "${attachment.fileExtension}${attachment.formattedFileSize}",
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodySmRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemAttachmentsListViewPreview(
@PreviewParameter(TimelineItemAttachmentsContentProvider::class) content: TimelineItemAttachmentsContent
) = ElementPreview {
TimelineItemAttachmentsListView(
content = content,
onGalleryItemClick = {},
onLinkClick = {},
onLinkLongClick = {},
onContentLayoutChange = {},
)
}
@@ -14,10 +14,12 @@ import io.element.android.features.messages.impl.timeline.TimelineEvent
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.rememberPresenter
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@@ -40,6 +42,7 @@ fun TimelineItemEventContentView(
content: TimelineItemEventContent,
hideMediaContent: Boolean,
onContentClick: (() -> Unit)?,
onGalleryItemClick: ((Int) -> Unit),
onLongClick: (() -> Unit)?,
onShowContentClick: () -> Unit,
onLinkClick: (Link) -> Unit,
@@ -90,6 +93,23 @@ fun TimelineItemEventContentView(
onContentLayoutChange = onContentLayoutChange,
modifier = modifier,
)
is TimelineItemGalleryContent -> TimelineItemGalleryView(
content = content,
onGalleryItemClick = { index -> onGalleryItemClick(index) },
onLongClick = onLongClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier,
)
is TimelineItemAttachmentsContent -> TimelineItemAttachmentsListView(
content = content,
onGalleryItemClick = { index -> onGalleryItemClick(index) },
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onContentLayoutChange = {},
modifier = modifier,
)
is TimelineItemStickerContent -> TimelineItemStickerView(
content = content,
hideMediaContent = hideMediaContent,
@@ -0,0 +1,138 @@
/*
* 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.features.messages.impl.timeline.components.event
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.timeline.model.event.GalleryItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
import io.element.android.libraries.matrix.api.media.MediaSource
import kotlinx.collections.immutable.toImmutableList
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
class TimelineItemGalleryContentProvider : PreviewParameterProvider<TimelineItemGalleryContent> {
override val values: Sequence<TimelineItemGalleryContent>
get() = sequenceOf(
aTimelineItemGalleryContent(
caption = "My vacation photos",
items = listOf(
aGalleryItem(),
aGalleryItem(type = GalleryItem.Type.Video, duration = 65.seconds),
aGalleryItem(),
aGalleryItem(),
),
),
aTimelineItemGalleryContent(
items = listOf(
aGalleryItem(),
),
),
aTimelineItemGalleryContent(
items = listOf(
aGalleryItem(width = 1920, height = 1080),
aGalleryItem(width = 1600, height = 900),
),
),
aTimelineItemGalleryContent(
items = listOf(
aGalleryItem(width = 1080, height = 1920),
aGalleryItem(width = 900, height = 1600),
),
),
aTimelineItemGalleryContent(
items = listOf(
aGalleryItem(width = 1920, height = 1080),
aGalleryItem(width = 1080, height = 1920),
),
),
aTimelineItemGalleryContent(
items = listOf(
aGalleryItem(type = GalleryItem.Type.Video, duration = 45.seconds),
aGalleryItem(),
aGalleryItem(),
),
),
aTimelineItemGalleryContent(
caption = "2 landscape + 1 portrait",
items = listOf(
aGalleryItem(width = 1920, height = 1080),
aGalleryItem(width = 1600, height = 900),
aGalleryItem(width = 1080, height = 1920),
),
),
aTimelineItemGalleryContent(
caption = "1 landscape + 2 portrait",
items = listOf(
aGalleryItem(width = 1920, height = 1080),
aGalleryItem(width = 1080, height = 1920),
aGalleryItem(width = 900, height = 1600),
),
),
aTimelineItemGalleryContent(
items = listOf(
aGalleryItem(),
aGalleryItem(),
aGalleryItem(type = GalleryItem.Type.Video, duration = 120.seconds),
aGalleryItem(),
aGalleryItem(),
),
),
aTimelineItemGalleryContent(
caption = "Many photos",
items = (1..12).map {
aGalleryItem(
type = if (it == 3) GalleryItem.Type.Video else GalleryItem.Type.Image,
)
},
),
)
}
fun aTimelineItemGalleryContent(
body: String = "Gallery",
caption: String? = null,
items: List<GalleryItem> = listOf(
aGalleryItem(),
aGalleryItem(),
aGalleryItem(),
aGalleryItem(),
),
) = TimelineItemGalleryContent(
body = body,
caption = caption,
formattedCaption = null,
isEdited = false,
items = items.toImmutableList(),
)
fun aGalleryItem(
filename: String = "photo.jpg",
type: GalleryItem.Type = GalleryItem.Type.Image,
width: Int = 400,
height: Int = 300,
duration: Duration = Duration.ZERO,
): GalleryItem {
return GalleryItem(
filename = filename,
mimeType = when (type) {
GalleryItem.Type.Video -> "video/mp4"
GalleryItem.Type.Audio -> "audio/mpeg"
GalleryItem.Type.File -> "application/pdf"
GalleryItem.Type.Image -> "image/jpeg"
},
mediaSource = MediaSource(url = "", json = ""),
thumbnailSource = null,
width = width,
height = height,
thumbnailWidth = width,
thumbnailHeight = height,
blurhash = null,
type = type,
duration = duration,
)
}
@@ -0,0 +1,384 @@
/*
* 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.features.messages.impl.timeline.components.event
import android.text.SpannedString
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.GalleryItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.bgSubtleTertiary
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.ui.utils.time.formatShort
import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link
import kotlinx.collections.immutable.ImmutableList
private const val MAX_TILES = 5
private val GALLERY_WIDTH = 264.dp
private val GRID_SPACING = 4.dp
private val GROUP_CORNER_RADIUS = 6.dp
private val SINGLE_IMAGE_HEIGHT = 130.dp
private val TWO_IMAGE_ROW_HEIGHT = 130.dp
private val THREE_IMAGE_ROW_HEIGHT = 85.dp
@Composable
fun TimelineItemGalleryView(
content: TimelineItemGalleryContent,
onGalleryItemClick: (Int) -> Unit,
onLongClick: (() -> Unit)?,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {
val totalItems = content.items.size
val showOverflow = totalItems > MAX_TILES
val overflowCount = totalItems - MAX_TILES
Column(modifier = modifier) {
val containerModifier = Modifier.clip(RoundedCornerShape(GROUP_CORNER_RADIUS))
Column(
modifier = containerModifier.width(GALLERY_WIDTH),
verticalArrangement = Arrangement.spacedBy(GRID_SPACING),
) {
when (totalItems) {
0 -> Unit
1 -> SingleItemLayout(
item = content.items[0],
onClick = { onGalleryItemClick(0) },
onLongClick = onLongClick,
)
2 -> TwoItemLayout(
items = content.items,
onItemClick = onGalleryItemClick,
onLongClick = onLongClick,
)
3 -> ThreeItemLayout(
items = content.items,
onItemClick = onGalleryItemClick,
onLongClick = onLongClick,
)
else -> FourPlusItemLayout(
items = content.items,
showOverflow = showOverflow,
overflowCount = overflowCount,
onItemClick = onGalleryItemClick,
onLongClick = onLongClick,
)
}
}
if (content.showCaption) {
Spacer(modifier = Modifier.height(8.dp))
val caption = if (LocalInspectionMode.current) {
SpannedString(content.caption)
} else {
content.formattedCaption ?: SpannedString(content.caption)
}
CompositionLocalProvider(
LocalContentColor provides ElementTheme.colors.textPrimary,
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
) {
EditorStyledText(
modifier = Modifier
.padding(horizontal = 4.dp)
.widthIn(min = 120.dp),
text = caption,
style = ElementRichTextEditorStyle.textStyle(),
onLinkClickedListener = onLinkClick,
onLinkLongClickedListener = onLinkLongClick,
releaseOnDetach = false,
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
)
}
}
}
}
@Composable
private fun SingleItemLayout(
item: GalleryItem,
onClick: () -> Unit,
onLongClick: (() -> Unit)?,
) {
GalleryItemCell(
item = item,
isLast = false,
remainingCount = 0,
onClick = onClick,
onLongClick = onLongClick,
modifier = Modifier
.width(GALLERY_WIDTH)
.height(SINGLE_IMAGE_HEIGHT),
)
}
@Composable
private fun TwoItemLayout(
items: ImmutableList<GalleryItem>,
onItemClick: (Int) -> Unit,
onLongClick: (() -> Unit)?,
) {
Column(
modifier = Modifier.width(GALLERY_WIDTH),
verticalArrangement = Arrangement.spacedBy(GRID_SPACING),
) {
items.forEachIndexed { index, item ->
GalleryItemCell(
item = item,
isLast = false,
remainingCount = 0,
onClick = { onItemClick(index) },
onLongClick = onLongClick,
modifier = Modifier
.fillMaxWidth()
.height(TWO_IMAGE_ROW_HEIGHT),
)
}
}
}
@Composable
private fun ThreeItemLayout(
items: ImmutableList<GalleryItem>,
onItemClick: (Int) -> Unit,
onLongClick: (() -> Unit)?,
) {
Column(
modifier = Modifier.width(GALLERY_WIDTH),
verticalArrangement = Arrangement.spacedBy(GRID_SPACING),
) {
GalleryItemCell(
item = items[0],
isLast = false,
remainingCount = 0,
onClick = { onItemClick(0) },
onLongClick = onLongClick,
modifier = Modifier
.fillMaxWidth()
.height(SINGLE_IMAGE_HEIGHT),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(GRID_SPACING),
) {
for (it in 1..2) {
GalleryItemCell(
item = items[it],
isLast = false,
remainingCount = 0,
onClick = { onItemClick(it) },
onLongClick = onLongClick,
modifier = Modifier
.weight(1f)
.height(TWO_IMAGE_ROW_HEIGHT),
)
}
}
}
}
@Composable
private fun FourPlusItemLayout(
items: ImmutableList<GalleryItem>,
showOverflow: Boolean,
overflowCount: Int,
onItemClick: (Int) -> Unit,
onLongClick: (() -> Unit)?,
) {
Column(
modifier = Modifier.width(GALLERY_WIDTH),
verticalArrangement = Arrangement.spacedBy(GRID_SPACING),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(GRID_SPACING),
) {
for (it in 0..1) {
GalleryItemCell(
item = items[it],
isLast = false,
remainingCount = 0,
onClick = { onItemClick(it) },
onLongClick = onLongClick,
modifier = Modifier
.weight(1f)
.height(TWO_IMAGE_ROW_HEIGHT),
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(GRID_SPACING),
) {
val bottomRowItems = if (showOverflow) 3 else minOf(items.size - 2, 3)
val bottomRowHeight = if (bottomRowItems == 3) THREE_IMAGE_ROW_HEIGHT else TWO_IMAGE_ROW_HEIGHT
for (i in 0 until bottomRowItems) {
val itemIndex = 2 + i
if (itemIndex < items.size) {
val isOverflowItem = showOverflow && i == bottomRowItems - 1
GalleryItemCell(
item = items[itemIndex],
isLast = isOverflowItem,
remainingCount = if (isOverflowItem) overflowCount else 0,
onClick = { onItemClick(itemIndex) },
onLongClick = onLongClick,
modifier = Modifier
.weight(1f)
.height(bottomRowHeight),
)
}
}
}
}
}
@Composable
private fun GalleryItemCell(
item: GalleryItem,
isLast: Boolean,
remainingCount: Int,
onClick: () -> Unit,
onLongClick: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.blurHashBackground(item.blurhash, alpha = 0.9f)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
),
contentAlignment = Alignment.Center,
) {
AsyncImage(
modifier = Modifier.fillMaxSize(),
model = item.thumbnailMediaRequestData,
contentScale = ContentScale.Crop,
alignment = Alignment.Center,
contentDescription = item.filename,
)
if (item.type == GalleryItem.Type.Video) {
VideoOverlay(duration = item.duration)
}
if (isLast && remainingCount > 0) {
RemainingCountOverlay(count = remainingCount)
}
}
}
@Composable
private fun VideoOverlay(duration: kotlin.time.Duration) {
val gradientColor = ElementTheme.colors.bgCanvasDefault
Box(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(80.dp)
.align(Alignment.BottomCenter)
.background(
brush = Brush.verticalGradient(
colors = listOf(gradientColor.copy(alpha = 0f), gradientColor.copy(alpha = 1f))
)
)
)
Row(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = CompoundIcons.VideoCallSolid(),
contentDescription = null,
tint = ElementTheme.colors.textPrimary,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = duration.formatShort(),
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontBodySmMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
@Composable
private fun RemainingCountOverlay(count: Int) {
Box(
modifier = Modifier
.fillMaxSize()
.background(ElementTheme.colors.bgSubtleTertiary.copy(alpha = 0.7f)),
contentAlignment = Alignment.Center,
) {
Text(
text = "+$count",
color = Color.White,
style = ElementTheme.typography.fontHeadingSmMedium,
)
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemGalleryViewPreview(
@PreviewParameter(TimelineItemGalleryContentProvider::class) content: TimelineItemGalleryContent,
) = ElementPreview {
TimelineItemGalleryView(
content = content,
onGalleryItemClick = {},
onLongClick = {},
onLinkClick = {},
onLinkLongClick = {},
onContentLayoutChange = {},
)
}
@@ -14,10 +14,14 @@ import androidx.core.text.toSpannable
import dev.zacsweers.metro.Inject
import io.element.android.features.location.api.Location
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.features.messages.impl.timeline.model.event.AttachmentItem
import io.element.android.features.messages.impl.timeline.model.event.GalleryItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent
@@ -35,6 +39,8 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser
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.MessageContent
@@ -263,6 +269,144 @@ class TimelineItemContentMessageFactory(
isEdited = content.isEdited,
)
}
is GalleryMessageType -> {
val dom = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
val formattedCaption = dom?.let(::parseHtml)
?: messageType.body.withLinks()
val galleryItems = messageType.items.mapNotNull { item ->
when (item) {
is GalleryItemType.Image -> {
GalleryItem(
filename = item.content.filename,
mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream,
mediaSource = item.content.source,
type = GalleryItem.Type.Image,
thumbnailSource = item.content.info?.thumbnailSource,
width = item.content.info?.width?.toInt(),
height = item.content.info?.height?.toInt(),
thumbnailWidth = item.content.info?.thumbnailInfo?.width?.toInt(),
thumbnailHeight = item.content.info?.thumbnailInfo?.height?.toInt(),
blurhash = item.content.info?.blurhash,
)
}
is GalleryItemType.Video -> {
GalleryItem(
filename = item.content.filename,
mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream,
mediaSource = item.content.source,
type = GalleryItem.Type.Video,
thumbnailSource = item.content.info?.thumbnailSource,
width = item.content.info?.width?.toInt(),
height = item.content.info?.height?.toInt(),
thumbnailWidth = item.content.info?.thumbnailInfo?.width?.toInt(),
thumbnailHeight = item.content.info?.thumbnailInfo?.height?.toInt(),
blurhash = item.content.info?.blurhash,
duration = item.content.info?.duration ?: Duration.ZERO,
)
}
is GalleryItemType.Audio -> {
GalleryItem(
filename = item.content.filename,
mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream,
mediaSource = item.content.source,
type = GalleryItem.Type.Audio,
thumbnailSource = null,
width = null,
height = null,
thumbnailWidth = null,
thumbnailHeight = null,
blurhash = null,
duration = item.content.info?.duration ?: Duration.ZERO,
)
}
is GalleryItemType.File -> {
GalleryItem(
filename = item.content.filename,
mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream,
mediaSource = item.content.source,
type = GalleryItem.Type.File,
thumbnailSource = item.content.info?.thumbnailSource,
width = null,
height = null,
thumbnailWidth = null,
thumbnailHeight = null,
blurhash = null,
)
}
is GalleryItemType.Other -> null
}
}
val hasPreviews = galleryItems.any { it.thumbnailSource != null }
val isOnlyVisualMedia = galleryItems.all { item ->
item.type.isVisualMedia()
}
if (isOnlyVisualMedia && hasPreviews) {
TimelineItemGalleryContent(
body = messageType.body,
caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() },
formattedCaption = formattedCaption,
isEdited = content.isEdited,
items = galleryItems.toImmutableList(),
)
} else {
val attachments = messageType.items.mapNotNull { item ->
when (item) {
is GalleryItemType.File -> {
AttachmentItem(
filename = item.content.filename,
mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream,
mediaSource = item.content.source,
thumbnailSource = item.content.info?.thumbnailSource,
fileSize = item.content.info?.size,
formattedFileSize = fileSizeFormatter.format(item.content.info?.size ?: 0L),
fileExtension = fileExtensionExtractor.extractFromName(item.content.filename),
)
}
is GalleryItemType.Image -> {
AttachmentItem(
filename = item.content.filename,
mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream,
mediaSource = item.content.source,
thumbnailSource = item.content.info?.thumbnailSource,
fileSize = item.content.info?.size,
formattedFileSize = fileSizeFormatter.format(item.content.info?.size ?: 0L),
fileExtension = fileExtensionExtractor.extractFromName(item.content.filename),
)
}
is GalleryItemType.Video -> {
AttachmentItem(
filename = item.content.filename,
mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream,
mediaSource = item.content.source,
thumbnailSource = item.content.info?.thumbnailSource,
fileSize = item.content.info?.size,
formattedFileSize = fileSizeFormatter.format(item.content.info?.size ?: 0L),
fileExtension = fileExtensionExtractor.extractFromName(item.content.filename),
)
}
is GalleryItemType.Audio -> {
AttachmentItem(
filename = item.content.filename,
mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream,
mediaSource = item.content.source,
thumbnailSource = null,
fileSize = item.content.info?.size,
formattedFileSize = fileSizeFormatter.format(item.content.info?.size ?: 0L),
fileExtension = fileExtensionExtractor.extractFromName(item.content.filename),
)
}
is GalleryItemType.Other -> null
}
}
TimelineItemAttachmentsContent(
body = messageType.body,
caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() },
formattedCaption = formattedCaption,
isEdited = content.isEdited,
attachments = attachments.toImmutableList(),
)
}
}
is OtherMessageType -> {
val body = messageType.body.trimEnd()
TimelineItemTextContent(
@@ -9,9 +9,11 @@
package io.element.android.features.messages.impl.timeline.groups
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@@ -52,6 +54,8 @@ internal fun TimelineItem.Event.canBeGrouped(): Boolean {
is TimelineItemTextBasedContent,
is TimelineItemEncryptedContent,
is TimelineItemImageContent,
is TimelineItemGalleryContent,
is TimelineItemAttachmentsContent,
is TimelineItemStickerContent,
is TimelineItemFileContent,
is TimelineItemVideoContent,
@@ -10,7 +10,9 @@ package io.element.android.features.messages.impl.timeline.model
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.timeline.components.MessageShieldData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
@@ -124,6 +126,8 @@ sealed interface TimelineItem {
is TimelineItemStickerContent -> content.formattedCaption == null && content.caption == null
is TimelineItemImageContent -> content.formattedCaption == null && content.caption == null
is TimelineItemVideoContent -> content.formattedCaption == null && content.caption == null
is TimelineItemGalleryContent -> false
is TimelineItemAttachmentsContent -> false
else -> true
}
@@ -0,0 +1,35 @@
/*
* 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.features.messages.impl.timeline.model.event
import io.element.android.libraries.matrix.api.media.MediaSource
import kotlinx.collections.immutable.ImmutableList
data class TimelineItemAttachmentsContent(
val body: String,
val caption: String?,
val formattedCaption: CharSequence?,
override val isEdited: Boolean,
val attachments: ImmutableList<AttachmentItem>,
) : TimelineItemEventContent, TimelineItemEventMutableContent {
override val type: String = "TimelineItemAttachmentsContent"
val showCaption = caption != null
val hasPreviews = attachments.any { it.thumbnailSource != null }
}
data class AttachmentItem(
val filename: String,
val mimeType: String,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val fileSize: Long?,
val formattedFileSize: String,
val fileExtension: String,
)
@@ -56,7 +56,9 @@ fun TimelineItemEventContent.canBeForwarded(): Boolean =
is TimelineItemAudioContent,
is TimelineItemVideoContent,
is TimelineItemLocationContent,
is TimelineItemVoiceContent -> true
is TimelineItemVoiceContent,
is TimelineItemGalleryContent,
is TimelineItemAttachmentsContent -> true
// Stickers can't be forwarded (yet) so we don't show the option
// See https://github.com/element-hq/element-x-android/issues/2161
is TimelineItemStickerContent -> false
@@ -78,7 +80,9 @@ fun TimelineItemEventContent.canReact(): Boolean =
is TimelineItemLocationContent,
is TimelineItemPollContent,
is TimelineItemVoiceContent,
is TimelineItemVideoContent -> true
is TimelineItemVideoContent,
is TimelineItemGalleryContent,
is TimelineItemAttachmentsContent -> true
is TimelineItemStateContent,
is TimelineItemRedactedContent,
is TimelineItemLegacyCallInviteContent,
@@ -99,6 +103,18 @@ fun TimelineItemEventContent.isEdited(): Boolean = when (this) {
*/
fun TimelineItemEventContent.isRedacted(): Boolean = this is TimelineItemRedactedContent
/**
* Returns the caption text for content types that support captions.
* Gallery and attachments content types have captions but don't implement
* [TimelineItemEventContentWithAttachment].
*/
fun TimelineItemEventContent.captionOrNull(): String? = when (this) {
is TimelineItemEventContentWithAttachment -> caption
is TimelineItemGalleryContent -> caption
is TimelineItemAttachmentsContent -> caption
else -> null
}
fun TimelineItemEventContentWithAttachment.duration(): Duration? {
return when (this) {
is TimelineItemAudioContent -> duration
@@ -13,7 +13,9 @@ import android.text.style.StyleSpan
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import kotlinx.collections.immutable.toImmutableList
import org.jsoup.nodes.Document
class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEventContent> {
@@ -111,3 +113,39 @@ fun aTimelineItemStateEventContent(
) = TimelineItemStateEventContent(
body = body,
)
fun aTimelineItemAttachmentsContent(
body: String = "Attachments",
caption: String? = null,
attachments: List<AttachmentItem> = listOf(
anAttachmentItem(filename = "document.pdf", fileExtension = "pdf"),
anAttachmentItem(filename = "recording.mp3", fileExtension = "mp3", fileSize = 4_500_000L, formattedFileSize = "4.5MB"),
),
) = TimelineItemAttachmentsContent(
body = body,
caption = caption,
formattedCaption = null,
isEdited = false,
attachments = attachments.toImmutableList(),
)
fun anAttachmentItem(
filename: String = "file.pdf",
fileExtension: String = "pdf",
fileSize: Long? = 1_000_000L,
formattedFileSize: String = "1MB",
mimeType: String? = null,
thumbnailSource: MediaSource? = null,
hasThumbnail: Boolean = false,
) = AttachmentItem(
filename = filename,
mimeType = mimeType ?: when {
hasThumbnail -> "image/jpeg"
else -> "application/$fileExtension"
},
mediaSource = MediaSource(url = "", json = ""),
thumbnailSource = thumbnailSource ?: if (hasThumbnail) MediaSource(url = "", json = "") else null,
fileSize = fileSize,
formattedFileSize = formattedFileSize,
fileExtension = fileExtension,
)
@@ -0,0 +1,66 @@
/*
* 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.features.messages.impl.timeline.model.event
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import kotlinx.collections.immutable.ImmutableList
import kotlin.time.Duration
data class TimelineItemGalleryContent(
val body: String,
val caption: String?,
val formattedCaption: CharSequence?,
override val isEdited: Boolean,
val items: ImmutableList<GalleryItem>,
) : TimelineItemEventContent, TimelineItemEventMutableContent {
override val type: String = "TimelineItemGalleryContent"
val showCaption = caption != null
}
data class GalleryItem(
val filename: String,
val mimeType: String,
val mediaSource: MediaSource,
val type: Type,
val thumbnailSource: MediaSource?,
val width: Int?,
val height: Int?,
val thumbnailWidth: Int?,
val thumbnailHeight: Int?,
val blurhash: String?,
val duration: Duration = Duration.ZERO,
) {
enum class Type {
Image,
Video,
Audio,
File;
fun isVisualMedia() = this in setOf(Image, Video)
}
val thumbnailMediaRequestData: MediaRequestData by lazy {
MediaRequestData(
source = thumbnailSource ?: mediaSource,
kind = MediaRequestData.Kind.Thumbnail(
width = thumbnailWidth?.toLong() ?: io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH,
height = thumbnailHeight?.toLong() ?: io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT,
),
)
}
val aspectRatio: Float? by lazy {
if (width != null && height != null && height > 0) {
width.toFloat() / height.toFloat()
} else {
null
}
}
}
@@ -9,10 +9,12 @@
package io.element.android.features.messages.impl.timeline.protection
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@@ -40,6 +42,8 @@ fun TimelineItem.mustBeProtected(): Boolean {
} else {
when (content) {
is TimelineItemImageContent,
is TimelineItemGalleryContent,
is TimelineItemAttachmentsContent,
is TimelineItemVideoContent,
is TimelineItemStickerContent -> true
is TimelineItemAudioContent,
@@ -11,10 +11,12 @@ package io.element.android.features.messages.impl.utils.messagesummary
import android.content.Context
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@@ -56,6 +58,16 @@ class DefaultMessageSummaryFormatter(
is TimelineItemVideoContent -> context.getString(CommonStrings.common_video)
is TimelineItemFileContent -> context.getString(CommonStrings.common_file)
is TimelineItemAudioContent -> context.getString(CommonStrings.common_audio)
is TimelineItemGalleryContent -> context.getString(CommonStrings.common_gallery)
is TimelineItemAttachmentsContent -> {
val count = content.attachments.size
val extensions = content.attachments.take(3).joinToString { it.fileExtension }
if (count <= 3) {
extensions
} else {
"$extensions +${count - 3}"
}
}
is TimelineItemLegacyCallInviteContent -> context.getString(CommonStrings.common_unsupported_call)
is TimelineItemRtcNotificationContent -> when (content.state) {
is RtcNotificationState.Declined -> {
@@ -68,6 +68,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureCalledOnceWithTwoParamsAndResult
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EnsureNeverCalledWithThreeParamsAndResult
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParamsAndResult
import io.element.android.tests.testutils.EventsRecorder
@@ -685,6 +686,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setMessagesView(
onBackClick: () -> Unit = EnsureNeverCalled(),
onRoomDetailsClick: () -> Unit = EnsureNeverCalled(),
onEventClick: (isLive: Boolean, event: TimelineItem.Event) -> Boolean = EnsureNeverCalledWithTwoParamsAndResult(),
onGalleryEventItemClick: (isLive: Boolean, event: TimelineItem.Event, index: Int) -> Boolean = EnsureNeverCalledWithThreeParamsAndResult(),
onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
onLinkClick: (String, Boolean) -> Unit = EnsureNeverCalledWithTwoParams(),
onSendLocationClick: () -> Unit = EnsureNeverCalled(),
@@ -701,6 +703,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setMessagesView(
onBackClick = onBackClick,
onRoomDetailsClick = onRoomDetailsClick,
onEventContentClick = onEventClick,
onGalleryEventItemClick = onGalleryEventItemClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onSendLocationClick = onSendLocationClick,
@@ -33,6 +33,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.core.EventId
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 io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
@@ -70,6 +71,7 @@ import io.element.android.tests.testutils.testCoroutineDispatchers
import io.mockk.every
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
@@ -92,6 +94,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
@Test
fun `present - initial state`() = runTest {
createAttachmentsPreviewPresenter().test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
}
@@ -116,13 +119,14 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
onDoneListener = { onDoneListener() },
)
presenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false))
initialState.eventSink(AttachmentsPreviewEvent.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = true))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo)))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(listOf(mediaUploadInfo)))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
sendFileResult.assertions().isCalledOnce()
onDoneListener.assertions().isCalledOnce()
@@ -150,6 +154,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
onDoneListener = { onDoneListener() },
)
presenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
// Pre-processing finishes
@@ -157,8 +162,8 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
advanceUntilIdle()
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false))
initialState.eventSink(AttachmentsPreviewEvent.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo)))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(listOf(mediaUploadInfo)))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
sendFileResult.assertions().isCalledOnce()
onDoneListener.assertions().isCalledOnce()
@@ -186,6 +191,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
onDoneListener = { onDoneListener() },
)
presenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false))
@@ -193,8 +199,8 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
// Pre-processing finishes
processLatch.complete(Unit)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = true))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo)))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(listOf(mediaUploadInfo)))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
sendFileResult.assertions().isCalledOnce()
onDoneListener.assertions().isCalledOnce()
@@ -215,6 +221,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
onDoneListener = { onDoneListener() },
)
presenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.eventSink(AttachmentsPreviewEvent.SendAttachment)
@@ -238,6 +245,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
onDoneListener = { onDoneListener() },
)
presenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
// Pre-processing finishes
@@ -260,6 +268,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
onDoneListener = { onDoneListener() },
)
presenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.eventSink(AttachmentsPreviewEvent.CancelAndDismiss)
@@ -291,6 +300,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
onDoneListener = { onDoneListener() },
)
presenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.textEditorState.setMarkdown(A_CAPTION)
@@ -332,6 +342,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
onDoneListener = { onDoneListener() },
)
presenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.textEditorState.setMarkdown(A_CAPTION)
@@ -373,6 +384,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
onDoneListener = { onDoneListener() },
)
presenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.textEditorState.setMarkdown(A_CAPTION)
@@ -407,22 +419,19 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
)
val presenter = createAttachmentsPreviewPresenter(room = room, onDoneListener = onDoneListenerResult)
presenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.eventSink(AttachmentsPreviewEvent.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo))
// Check that the onDoneListener is called so the screen would be dismissed
onDoneListenerResult.assertions().isCalledOnce()
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo)))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(listOf(mediaUploadInfo)))
val failureState = awaitItem()
assertThat(failureState.sendActionState).isEqualTo(SendActionState.Failure(failure, mediaUploadInfo))
assertThat(failureState.sendActionState).isEqualTo(SendActionState.Failure(failure, listOf(mediaUploadInfo)))
sendFileResult.assertions().isCalledOnce()
failureState.eventSink(AttachmentsPreviewEvent.CancelAndClearSendState)
val clearedState = awaitLastSequentialItem()
assertThat(clearedState.sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo))
assertThat(clearedState.sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo)))
}
}
@@ -440,19 +449,17 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
onDoneListener = onDoneListenerResult,
)
presenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.eventSink(AttachmentsPreviewEvent.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo)))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(listOf(mediaUploadInfo)))
initialState.eventSink(AttachmentsPreviewEvent.CancelAndClearSendState)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo)))
// The sending is cancelled and the state is kept at ReadyToUpload
ensureAllEventsConsumed()
// Check that the onDoneListener is called so the screen would be dismissed
onDoneListenerResult.assertions().isCalledOnce()
}
}
@@ -464,7 +471,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
val maxUploadSize = 999L // Set a max upload size smaller than the file size
val presenter = createAttachmentsPreviewPresenter(
localMedia = localMedia,
attachments = listOf(Attachment.Media(localMedia)),
room = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply {
sendFileLambda = { _, _, _, _, _ ->
@@ -475,6 +482,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
onDoneListener = onDoneListenerResult,
mediaOptimizationSelectorPresenterFactory = FakeMediaOptimizationSelectorPresenterFactory {
MediaOptimizationSelectorState(
index = 0,
// Set a max upload size smaller than the file size
maxUploadSize = AsyncData.Success(maxUploadSize),
videoSizeEstimations = AsyncData.Uninitialized,
@@ -503,7 +511,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
val localMedia = aLocalMedia(uri = Uri.EMPTY, mediaInfo = aVideoMediaInfo())
val presenter = createAttachmentsPreviewPresenter(
localMedia = localMedia,
attachments = listOf(Attachment.Media(localMedia)),
room = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply {
sendFileLambda = { _, _, _, _, _ ->
@@ -514,6 +522,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
onDoneListener = onDoneListenerResult,
mediaOptimizationSelectorPresenterFactory = FakeMediaOptimizationSelectorPresenterFactory {
MediaOptimizationSelectorState(
index = 0,
// Set a max upload size smaller than the file size
maxUploadSize = AsyncData.Success(Long.MAX_VALUE),
videoSizeEstimations = AsyncData.Success(
@@ -571,6 +580,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
)
presenter.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
val editorState = awaitItem()
@@ -584,7 +594,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
assertThat(awaitItem().isApplyingImageEdits).isTrue()
val appliedState = awaitItem()
assertThat((appliedState.attachment as Attachment.Media).localMedia.uri).isEqualTo(editedUri)
assertThat((appliedState.attachments.first() as Attachment.Media).localMedia.uri).isEqualTo(editedUri)
assertThat(appliedState.imageEditorState).isNull()
assertThat(appliedState.isApplyingImageEdits).isFalse()
}
@@ -601,7 +611,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
bottom = 0.9f,
)
val presenter = createAttachmentsPreviewPresenter(
localMedia = originalLocalMedia,
attachments = listOf(Attachment.Media(originalLocalMedia)),
displayMediaQualitySelectorViews = true,
attachmentImageEditor = FakeAttachmentImageEditor {
Result.success(
@@ -629,7 +639,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
flippedState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits)
val appliedState = consumeItemsUntilPredicate { !it.isApplyingImageEdits && it.imageEditorState == null }.last()
assertThat((appliedState.attachment as Attachment.Media).localMedia.uri).isEqualTo(editedUri)
assertThat((appliedState.attachments.first() as Attachment.Media).localMedia.uri).isEqualTo(editedUri)
appliedState.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
val reopenedState = consumeItemsUntilPredicate { it.imageEditorState != null }.last()
@@ -673,21 +683,26 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
}
}
@Test
fun `present - sendAsFile attachment is pre-processed without image compression`() = runTest {
// Even though the user has enabled "Optimize media quality" globally, picking the file
// through the Files picker (sendAsFile = true) must skip compression. Regression test
// for https://github.com/element-hq/element-x-android/issues/6365
val mediaPreProcessor = FakeMediaPreProcessor()
val presenter = createAttachmentsPreviewPresenter(
localMedia = aLocalMedia(mockMediaUrl, anImageMediaInfo()),
sendAsFile = true,
attachments = listOf(
Attachment.Media(
localMedia = aLocalMedia(mockMediaUrl, anImageMediaInfo()),
sendAsFile = true,
)
),
mediaPreProcessor = mediaPreProcessor,
// Selector views are hidden in the sendAsFile flow, which triggers the auto pre-process path.
displayMediaQualitySelectorViews = false,
mediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(
config = MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
compressImages = false,
videoCompressionPreset = VideoCompressionPreset.HIGH,
)
),
)
@@ -772,9 +787,11 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
fileExtension = "png",
),
)
val presenter = createAttachmentsPreviewPresenter(localMedia = localMedia)
val presenter = createAttachmentsPreviewPresenter(
attachments = listOf(Attachment.Media(localMedia)),
)
presenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.canEditImage).isTrue()
@@ -795,7 +812,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
),
)
val presenter = createAttachmentsPreviewPresenter(
localMedia = localMedia,
attachments = listOf(Attachment.Media(localMedia)),
attachmentImageEditor = FakeAttachmentImageEditor(
canEditResult = true,
) {
@@ -822,13 +839,18 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
fun `present - sendAsFile video is pre-processed with best fitting preset`() = runTest {
val mediaPreProcessor = FakeMediaPreProcessor()
val presenter = createAttachmentsPreviewPresenter(
localMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()),
sendAsFile = true,
attachments = listOf(
Attachment.Media(
localMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()),
sendAsFile = true,
)
),
mediaPreProcessor = mediaPreProcessor,
// Selector views are hidden in the sendAsFile flow, which triggers the auto pre-process path.
displayMediaQualitySelectorViews = false,
mediaOptimizationSelectorPresenterFactory = FakeMediaOptimizationSelectorPresenterFactory {
MediaOptimizationSelectorState(
index = 0,
maxUploadSize = AsyncData.Success(250_000_000L),
videoSizeEstimations = AsyncData.Success(
persistentListOf(
@@ -863,11 +885,62 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
}
}
@Test
fun `present - sending gallery after image edits restarts preprocessing`() = runTest {
val sendGalleryResult =
lambdaRecorder<List<GalleryItemInfo>, String?, String?, EventId?, Result<FakeMediaUploadHandler>> { _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val firstLocalMedia = aLocalMedia(uri = Uri.parse("file:///tmp/original-1.jpeg"))
val secondLocalMedia = aLocalMedia(uri = Uri.parse("file:///tmp/original-2.jpeg"))
val editedUri = Uri.parse("file:///tmp/edited-1.jpeg")
val onDoneListener = lambdaRecorder<Unit> { }
val presenter = createAttachmentsPreviewPresenter(
room = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply {
sendGalleryLambda = sendGalleryResult
},
),
attachments = persistentListOf(
aMediaAttachment(firstLocalMedia),
aMediaAttachment(secondLocalMedia),
),
displayMediaQualitySelectorViews = false,
attachmentImageEditor = FakeAttachmentImageEditor {
Result.success(
EditedLocalMedia(
localMedia = firstLocalMedia.copy(uri = editedUri),
file = File("/tmp/edited-1.jpeg"),
)
)
},
onDoneListener = OnDoneListener { onDoneListener() },
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
val editorState = consumeItemsUntilPredicate { it.imageEditorState != null }.last()
editorState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits)
val appliedState = consumeItemsUntilPredicate { !it.isApplyingImageEdits && it.imageEditorState == null }.last()
appliedState.eventSink(AttachmentsPreviewEvent.SendAttachment)
consumeItemsUntilPredicate { it.sendActionState == SendActionState.Done }
sendGalleryResult.assertions().isCalledOnce()
onDoneListener.assertions().isCalledOnce()
}
}
private fun TestScope.createAttachmentsPreviewPresenter(
localMedia: LocalMedia = aLocalMedia(
uri = mockMediaUrl,
attachments: List<Attachment> = listOf(
aMediaAttachment(
aLocalMedia(
uri = mockMediaUrl,
)
),
),
sendAsFile: Boolean = false,
room: JoinedRoom = FakeJoinedRoom(),
timelineMode: Timeline.Mode = Timeline.Mode.Live,
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
@@ -878,6 +951,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
mediaOptimizationSelectorPresenterFactory: FakeMediaOptimizationSelectorPresenterFactory = FakeMediaOptimizationSelectorPresenterFactory(
fakePresenter = {
MediaOptimizationSelectorState(
index = 0,
maxUploadSize = AsyncData.Uninitialized,
videoSizeEstimations = AsyncData.Uninitialized,
isImageOptimizationEnabled = null,
@@ -890,17 +964,22 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
),
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
attachmentImageEditor: AttachmentImageEditor = FakeAttachmentImageEditor {
Result.success(
EditedLocalMedia(
localMedia = localMedia.copy(uri = Uri.parse("file:///tmp/default-edited.jpeg")),
file = File("/tmp/default-edited.jpeg"),
val localMediaResult = (attachments.first() as? Attachment.Media)?.localMedia?.copy(uri = Uri.parse("file:///tmp/default-edited.jpeg"))
if (localMediaResult != null) {
Result.success(
EditedLocalMedia(
localMedia = localMediaResult,
file = File("/tmp/default-edited.jpeg"),
)
)
)
} else {
Result.failure(IllegalStateException("Check test values"))
}
},
videoCompressionPresetSelector: VideoCompressionPresetSelector = VideoCompressionPresetSelector(),
): AttachmentsPreviewPresenter {
return AttachmentsPreviewPresenter(
attachment = aMediaAttachment(localMedia, sendAsFile = sendAsFile),
attachments = attachments.toImmutableList(),
onDoneListener = onDoneListener,
mediaSenderFactory = MediaSenderFactory { timelineMode ->
DefaultMediaSender(
@@ -11,28 +11,27 @@ package io.element.android.features.messages.impl.attachments
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.attachments.preview.SendActionState
import io.element.android.features.messages.impl.attachments.preview.aMediaUploadInfo
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import org.junit.Test
class SendActionStateTest {
@Test
fun `mediaUploadInfo() should return the value from Uploading class`() {
val mediaUploadInfo: MediaUploadInfo = aMediaUploadInfo()
val state: SendActionState = SendActionState.Sending.Uploading(mediaUploadInfo = aMediaUploadInfo())
assertThat(state.mediaUploadInfo()).isEqualTo(mediaUploadInfo)
fun `mediaUploadInfoList() should return the value from Uploading class`() {
val data = listOf(aMediaUploadInfo())
val state: SendActionState = SendActionState.Sending.Uploading(mediaInfos = data)
assertThat(state.mediaUploadInfoList()).isEqualTo(data)
}
@Test
fun `mediaUploadInfo() should return the value from ReadyToUpload class`() {
val mediaUploadInfo: MediaUploadInfo = aMediaUploadInfo()
val state: SendActionState = SendActionState.Sending.ReadyToUpload(mediaInfo = aMediaUploadInfo())
assertThat(state.mediaUploadInfo()).isEqualTo(mediaUploadInfo)
fun `mediaUploadInfoList() should return the value from ReadyToUpload class`() {
val data = listOf(aMediaUploadInfo())
val state: SendActionState = SendActionState.Sending.ReadyToUpload(mediaInfos = data)
assertThat(state.mediaUploadInfoList()).isEqualTo(data)
}
@Test
fun `mediaUploadInfo() should return the value from Failure class`() {
val mediaUploadInfo: MediaUploadInfo = aMediaUploadInfo()
val state: SendActionState = SendActionState.Failure(error = IllegalStateException("An error"), mediaUploadInfo = aMediaUploadInfo())
assertThat(state.mediaUploadInfo()).isEqualTo(mediaUploadInfo)
fun `mediaUploadInfoList() should return the value from Failure class`() {
val data = listOf(aMediaUploadInfo())
val state: SendActionState = SendActionState.Failure(error = IllegalStateException("An error"), mediaInfos = data)
assertThat(state.mediaUploadInfoList()).isEqualTo(data)
}
}
@@ -274,6 +274,7 @@ class DefaultMediaOptimizationSelectorPresenterTest : RobolectricTest() {
}
private fun createDefaultMediaOptimizationSelectorPresenter(
index: Int = 0,
localMedia: LocalMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()),
maxUploadSizeProvider: MaxUploadSizeProvider = MaxUploadSizeProvider { Result.success(1_000L) },
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SelectableMediaQuality.key to true)),
@@ -283,6 +284,7 @@ class DefaultMediaOptimizationSelectorPresenterTest : RobolectricTest() {
sendAsFile: Boolean = false,
): DefaultMediaOptimizationSelectorPresenter {
return DefaultMediaOptimizationSelectorPresenter(
index = index,
localMedia = localMedia,
sendAsFile = sendAsFile,
maxUploadSizeProvider = maxUploadSizeProvider,
@@ -24,6 +24,7 @@ import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter
import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper
import io.element.android.features.messages.impl.utils.TextPillificationHelper
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
@@ -274,6 +275,7 @@ class MessageComposerPresenterSlashCommandTest {
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
threadRoot: ThreadId? = null,
slashCommandService: SlashCommandService = FakeSlashCommandService(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
) = MessageComposerPresenter(
navigator = navigator,
sessionCoroutineScope = this,
@@ -312,6 +314,7 @@ class MessageComposerPresenterSlashCommandTest {
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
notificationConversationService = notificationConversationService,
slashCommandService = slashCommandService,
featureFlagService = featureFlagService,
).apply {
isTesting = true
showTextFormatting = isRichTextEditorEnabled
@@ -34,6 +34,7 @@ import io.element.android.features.messages.impl.utils.TextPillificationHelper
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
@@ -1534,6 +1535,7 @@ class MessageComposerPresenterTest : RobolectricTest() {
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
threadRoot: ThreadId? = null,
slashCommandService: SlashCommandService = FakeSlashCommandService(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
) = MessageComposerPresenter(
navigator = navigator,
sessionCoroutineScope = this,
@@ -1572,6 +1574,7 @@ class MessageComposerPresenterTest : RobolectricTest() {
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
notificationConversationService = notificationConversationService,
slashCommandService = slashCommandService,
featureFlagService = featureFlagService,
).apply {
isTesting = true
showTextFormatting = isRichTextEditorEnabled
@@ -27,6 +27,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
@@ -96,6 +97,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setPinnedMessagesListView(
state: PinnedMessagesListState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onEventClick: (event: TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
onGalleryItemClick: (event: TimelineItem.Event, index: Int) -> Unit = EnsureNeverCalledWithTwoParams(),
onUserDataClick: (MatrixUser) -> Unit = EnsureNeverCalledWithParam(),
onLinkClick: (Link) -> Unit = EnsureNeverCalledWithParam(),
onLinkLongClick: (Link) -> Unit = EnsureNeverCalledWithParam(),
@@ -105,6 +107,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setPinnedMessagesListView(
state = state,
onBackClick = onBackClick,
onEventClick = onEventClick,
onGalleryItemClick = onGalleryItemClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
@@ -216,6 +216,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setTimelineView(
onReactionLongClick: (emoji: String, TimelineItem.Event) -> Unit = EnsureNeverCalledWithTwoParams(),
onMoreReactionsClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
onReadReceiptClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
onGalleryItemClick: (TimelineItem.Event, Int) -> Unit = EnsureNeverCalledWithTwoParams(),
forceJumpToBottomVisibility: Boolean = false,
) {
setSafeContent(clearAndroidUiDispatcher = true) {
@@ -231,6 +232,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setTimelineView(
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
onGalleryItemClick = onGalleryItemClick,
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
)
}
@@ -19,9 +19,13 @@ import androidx.core.text.inSpans
import androidx.core.text.toSpannable
import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.Location
import io.element.android.features.messages.impl.timeline.model.event.AttachmentItem
import io.element.android.features.messages.impl.timeline.model.event.GalleryItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent
@@ -47,6 +51,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
@@ -767,6 +773,428 @@ class TimelineItemContentMessageFactoryTest : RobolectricTest() {
(result as TimelineItemTextContent).formattedBody.assertSpannedEquals(expectedSpanned)
}
@Test
fun `test create GalleryMessageType with image items returns TimelineItemGalleryContent`() = runTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(
type = 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(
height = 100L,
width = 200L,
mimetype = MimeTypes.Jpeg,
size = 888L,
thumbnailInfo = ThumbnailInfo(height = 10L, width = 20L, mimetype = MimeTypes.Jpeg, size = 111L),
thumbnailSource = MediaSource("thumbnail_url"),
blurhash = A_BLUR_HASH,
)
)
)
)
)
),
senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID,
)
val expected = TimelineItemGalleryContent(
body = "Gallery body",
caption = "Gallery body",
formattedCaption = null,
isEdited = false,
items = persistentListOf(
GalleryItem(
filename = "image.jpg",
mimeType = MimeTypes.Jpeg,
mediaSource = MediaSource("image_url"),
type = GalleryItem.Type.Image,
thumbnailSource = MediaSource("thumbnail_url"),
width = 200,
height = 100,
thumbnailWidth = 20,
thumbnailHeight = 10,
blurhash = A_BLUR_HASH,
)
)
)
assertThat(result).isEqualTo(expected)
}
@Test
fun `test create GalleryMessageType with video items returns TimelineItemGalleryContent`() = runTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(
type = GalleryMessageType(
body = "Gallery body",
formatted = null,
items = listOf(
GalleryItemType.Video(
content = VideoMessageType(
filename = "video.mp4",
caption = null,
formattedCaption = null,
source = MediaSource("video_url"),
info = VideoInfo(
duration = 1.minutes,
height = 100L,
width = 200L,
mimetype = MimeTypes.Mp4,
size = 1234L,
thumbnailInfo = ThumbnailInfo(height = 10L, width = 20L, mimetype = MimeTypes.Jpeg, size = 111L),
thumbnailSource = MediaSource("thumbnail_url"),
blurhash = A_BLUR_HASH,
)
)
)
)
)
),
senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID,
)
val expected = TimelineItemGalleryContent(
body = "Gallery body",
caption = "Gallery body",
formattedCaption = null,
isEdited = false,
items = persistentListOf(
GalleryItem(
filename = "video.mp4",
mimeType = MimeTypes.Mp4,
mediaSource = MediaSource("video_url"),
type = GalleryItem.Type.Video,
thumbnailSource = MediaSource("thumbnail_url"),
width = 200,
height = 100,
thumbnailWidth = 20,
thumbnailHeight = 10,
blurhash = A_BLUR_HASH,
duration = 1.minutes,
)
)
)
assertThat(result).isEqualTo(expected)
}
@Test
fun `test create GalleryMessageType with audio items returns TimelineItemAttachmentsContent`() = runTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(
type = GalleryMessageType(
body = "Gallery body",
formatted = null,
items = listOf(
GalleryItemType.Audio(
content = AudioMessageType(
filename = "audio.mp3",
caption = null,
formattedCaption = null,
source = MediaSource("audio_url"),
info = AudioInfo(
duration = 1.minutes,
size = 123L,
mimetype = MimeTypes.Mp3,
)
)
)
)
)
),
senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID,
)
val expected = TimelineItemAttachmentsContent(
body = "Gallery body",
caption = "Gallery body",
formattedCaption = null,
isEdited = false,
attachments = persistentListOf(
AttachmentItem(
filename = "audio.mp3",
mimeType = MimeTypes.Mp3,
mediaSource = MediaSource("audio_url"),
thumbnailSource = null,
fileSize = 123L,
formattedFileSize = "123 Bytes",
fileExtension = "mp3",
)
)
)
assertThat(result).isEqualTo(expected)
}
@Test
fun `test create GalleryMessageType with file items returns TimelineItemAttachmentsContent`() = runTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(
type = GalleryMessageType(
body = "Gallery body",
formatted = null,
items = listOf(
GalleryItemType.File(
content = FileMessageType(
filename = "document.pdf",
caption = null,
formattedCaption = null,
source = MediaSource("file_url"),
info = FileInfo(
mimetype = MimeTypes.Pdf,
size = 456L,
thumbnailInfo = null,
thumbnailSource = null,
)
)
)
)
)
),
senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID,
)
val expected = TimelineItemAttachmentsContent(
body = "Gallery body",
caption = "Gallery body",
formattedCaption = null,
isEdited = false,
attachments = persistentListOf(
AttachmentItem(
filename = "document.pdf",
mimeType = MimeTypes.Pdf,
mediaSource = MediaSource("file_url"),
thumbnailSource = null,
fileSize = 456L,
formattedFileSize = "456 Bytes",
fileExtension = "pdf",
)
)
)
assertThat(result).isEqualTo(expected)
}
@Test
fun `test create GalleryMessageType with image items without thumbnails returns TimelineItemAttachmentsContent`() = runTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(
type = 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(
height = 100L,
width = 200L,
mimetype = MimeTypes.Jpeg,
size = 888L,
thumbnailInfo = null,
thumbnailSource = null,
blurhash = null,
)
)
)
)
)
),
senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID,
)
val expected = TimelineItemAttachmentsContent(
body = "Gallery body",
caption = "Gallery body",
formattedCaption = null,
isEdited = false,
attachments = persistentListOf(
AttachmentItem(
filename = "image.jpg",
mimeType = MimeTypes.Jpeg,
mediaSource = MediaSource("image_url"),
thumbnailSource = null,
fileSize = 888L,
formattedFileSize = "888 Bytes",
fileExtension = "jpg",
)
)
)
assertThat(result).isEqualTo(expected)
}
@Test
fun `test create GalleryMessageType with only Other items returns TimelineItemAttachmentsContent with empty attachments`() = runTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(
type = GalleryMessageType(
body = "Gallery body",
formatted = null,
items = listOf(
GalleryItemType.Other(itemType = "unknown_type", body = "Some body")
)
)
),
senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID,
)
val expected = TimelineItemAttachmentsContent(
body = "Gallery body",
caption = "Gallery body",
formattedCaption = null,
isEdited = false,
attachments = persistentListOf(),
)
assertThat(result).isEqualTo(expected)
}
@Test
fun `test create GalleryMessageType with mixed image and file items returns TimelineItemAttachmentsContent`() = runTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(
type = 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(
height = 100L,
width = 200L,
mimetype = MimeTypes.Jpeg,
size = 888L,
thumbnailInfo = ThumbnailInfo(height = 10L, width = 20L, mimetype = MimeTypes.Jpeg, size = 111L),
thumbnailSource = MediaSource("thumbnail_url"),
blurhash = null,
)
)
),
GalleryItemType.File(
content = FileMessageType(
filename = "document.pdf",
caption = null,
formattedCaption = null,
source = MediaSource("file_url"),
info = FileInfo(
mimetype = MimeTypes.Pdf,
size = 456L,
thumbnailInfo = null,
thumbnailSource = null,
)
)
)
)
)
),
senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID,
)
assertThat(result).isInstanceOf(TimelineItemAttachmentsContent::class.java)
val attachmentsContent = result as TimelineItemAttachmentsContent
assertThat(attachmentsContent.attachments).hasSize(2)
}
@Test
fun `test create GalleryMessageType with formatted caption returns TimelineItemGalleryContent with formatted caption`() = runTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(
type = GalleryMessageType(
body = "Gallery body",
formatted = FormattedBody(MessageFormat.HTML, "formatted"),
items = listOf(
GalleryItemType.Image(
content = ImageMessageType(
filename = "image.jpg",
caption = null,
formattedCaption = null,
source = MediaSource("image_url"),
info = ImageInfo(
height = 100L,
width = 200L,
mimetype = MimeTypes.Jpeg,
size = 888L,
thumbnailInfo = ThumbnailInfo(height = 10L, width = 20L, mimetype = MimeTypes.Jpeg, size = 111L),
thumbnailSource = MediaSource("thumbnail_url"),
blurhash = null,
)
)
)
)
)
),
senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID,
)
assertThat(result).isInstanceOf(TimelineItemGalleryContent::class.java)
val galleryContent = result as TimelineItemGalleryContent
galleryContent.formattedCaption.assertSpannedEquals(SpannedString("formatted"))
}
@Test
fun `test create GalleryMessageType with empty body has no caption`() = runTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(
type = GalleryMessageType(
body = "",
formatted = null,
items = listOf(
GalleryItemType.Image(
content = ImageMessageType(
filename = "image.jpg",
caption = null,
formattedCaption = null,
source = MediaSource("image_url"),
info = ImageInfo(
height = 100L,
width = 200L,
mimetype = MimeTypes.Jpeg,
size = 888L,
thumbnailInfo = ThumbnailInfo(height = 10L, width = 20L, mimetype = MimeTypes.Jpeg, size = 111L),
thumbnailSource = MediaSource("thumbnail_url"),
blurhash = null,
)
)
)
)
)
),
senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID,
)
assertThat(result).isInstanceOf(TimelineItemGalleryContent::class.java)
val galleryContent = result as TimelineItemGalleryContent
assertThat(galleryContent.caption).isNull()
assertThat(galleryContent.formattedCaption).isNull()
}
private fun createMessageContent(
body: String = "Body",
inReplyTo: InReplyTo? = null,
@@ -16,6 +16,7 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMedia
class FakeMediaOptimizationSelectorPresenterFactory(
private val fakePresenter: MediaOptimizationSelectorPresenter = MediaOptimizationSelectorPresenter {
MediaOptimizationSelectorState(
index = 0,
maxUploadSize = AsyncData.Uninitialized,
videoSizeEstimations = AsyncData.Uninitialized,
isImageOptimizationEnabled = null,
@@ -26,7 +27,7 @@ class FakeMediaOptimizationSelectorPresenterFactory(
)
}
) : MediaOptimizationSelectorPresenter.Factory {
override fun create(localMedia: LocalMedia, sendAsFile: Boolean): MediaOptimizationSelectorPresenter {
override fun create(index: Int, localMedia: LocalMedia, sendAsFile: Boolean): MediaOptimizationSelectorPresenter {
return fakePresenter
}
}
@@ -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
}
@@ -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
}
@@ -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"
@@ -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")
@@ -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,
),
}
@@ -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
}
@@ -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>
@@ -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,
@@ -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,
)
}
}
@@ -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() }
}
}
@@ -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),
@@ -219,6 +219,7 @@ class JoinedRustRoom(
RoomMessageEventMessageType.IMAGE,
RoomMessageEventMessageType.VIDEO,
RoomMessageEventMessageType.AUDIO,
RoomMessageEventMessageType.GALLERY,
)
)
is CreateTimelineParams.Focused,
@@ -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>,
@@ -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,
)
}
}
}
@@ -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,
)
@@ -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"}"""
}
@@ -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")
),
)
)
}
}
@@ -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,
@@ -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>
@@ -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)
}
}
}
@@ -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 {
@@ -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) }
@@ -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()
}
@@ -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,
)
}
}
@@ -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)
@@ -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()
}
@@ -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,
}
}
@@ -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,
@@ -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
@@ -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,
)
}
@@ -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,
)
}
@@ -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
}
@@ -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,
)
@@ -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(),
)
)
}
}
}
@@ -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
@@ -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
}
}
@@ -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
}
@@ -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())
}
}
@@ -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"),
@@ -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)
@@ -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,
@@ -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,
)
}
@@ -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,
)
@@ -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,
)
}
@@ -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
}
}
@@ -140,7 +140,10 @@ class KonsistClassNameTest {
Konsist.scopeFromProject()
.classes()
.withNameEndingWith("Impl")
.withoutName("MediaUploadHandlerImpl")
.withoutName(
"MediaUploadHandlerImpl",
"GalleryMediaUploadHandlerImpl",
)
.assertEmpty(additionalMessage = "Class implementing interface should have name not end with 'Impl' but start with 'Default'")
}

Some files were not shown because too many files have changed in this diff Show More