Support gallery messages (#6519)

* Support gallery messages

* Address review

* Remove unused function

* Fix indent

* Add "Gallery" message prefix

* Support galleries for image editing

* Fix tapping on a tile opens the last item

* Fix overflow count

* Add caption editing to gallery messages

* Use the new gallery string for prefix

* Lock sending galleries behind a feature flag

* Fix detekts

* Fix

* Ensure image edition is saved when navigating

* Fix sending media broken on edited gallery.

* Fix tests

* Order imports

* Remove unused parameters.

* Fix copyright header of new files.

* Fix Konsist test

* Extract new previews to a dedicated file.

* Sync strings

* Remove unused import

* Update screenshots

* Trigger CI

* Remove parameters with default value.

* More cleanup

* Restore sendAsFile behavior.

* Improve Preview.

* Improve Preview.

* Improve Preview.

* Fix gallery sending cancel and retry issue

* Ensure any previous job is cancelled.

* Fix issue in summary message

* Gallery feature is disabled by default.

* Kotlin convention

* Remove useless parenthesis

* Update screenshots

* Fix test

* List -> ImmutableList

* Remove useless code.

* Render formatted caption for attachment list.

* Replace set of Booleans by an enum

* Remove unused model for individual caption in a gallery Event.

* Fix tests

* Fix tests

* Rework MediaViewer entry point.

And ensure that the clicked image from the gallery is displayed first.

* Ensure gallery item can be click in the pinned message list
Improve the gallery item click handling code.

* Improve code and fix separator color
Closes #7101

* React on attachment item click
Improve code

* Improve code and support 0 items in gallery.

* Fix click on attachment item not rendering anything.

---------

Co-authored-by: Benoit Marty <benoitm@element.io>
Co-authored-by: Benoit Marty <benoit@matrix.org>
Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
bxdxnn
2026-06-26 17:00:56 +03:00
committed by GitHub
parent 9e7ae34728
commit 87aff40b26
180 changed files with 3470 additions and 637 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.TimelineController
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode 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.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.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment 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.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.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent 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.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.RoomNamesCache 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.MediaInfo
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater
@@ -153,7 +158,15 @@ class MessagesFlowNode(
) : NavTarget ) : NavTarget
@Parcelize @Parcelize
data class AttachmentPreview(val timelineMode: Timeline.Mode, val attachment: Attachment, val inReplyToEventId: EventId?) : NavTarget data class GalleryViewer(
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 @Parcelize
data class LocationViewer(val mode: ShowLocationMode) : NavTarget data class LocationViewer(val mode: ShowLocationMode) : NavTarget
@@ -239,7 +252,11 @@ class MessagesFlowNode(
callback.navigateToRoomDetails() 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( return processEventClick(
timelineMode = timelineMode, timelineMode = timelineMode,
event = event, event = event,
@@ -247,10 +264,22 @@ class MessagesFlowNode(
) )
} }
override fun handleGalleryItemClick(
event: TimelineItem.Event,
galleryItemIndex: Int,
canUseOverlay: Boolean,
): Boolean {
return processGalleryEventClick(
event = event,
canUseOverlay = canUseOverlay,
galleryItemIndex = galleryItemIndex,
)
}
override fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) { override fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) {
backstack.push( backstack.push(
NavTarget.AttachmentPreview( NavTarget.AttachmentPreview(
attachment = attachments.first(), attachments = attachments,
timelineMode = Timeline.Mode.Live, timelineMode = Timeline.Mode.Live,
inReplyToEventId = inReplyToEventId, inReplyToEventId = inReplyToEventId,
) )
@@ -339,13 +368,43 @@ class MessagesFlowNode(
createNode<MessagesNode>(buildContext, listOf(callback, inputs)) createNode<MessagesNode>(buildContext, listOf(callback, inputs))
} }
is NavTarget.MediaViewer -> { is NavTarget.MediaViewer -> {
val params = MediaViewerEntryPoint.Params( val params = MediaViewerEntryPoint.Params.RoomMedia(
mode = navTarget.mode, mode = navTarget.mode,
eventId = navTarget.eventId, eventId = navTarget.eventId,
mediaInfo = navTarget.mediaInfo, mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource, mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource, 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,
) )
val callback = object : MediaViewerEntryPoint.Callback { val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() { override fun onDone() {
@@ -374,7 +433,7 @@ class MessagesFlowNode(
} }
is NavTarget.AttachmentPreview -> { is NavTarget.AttachmentPreview -> {
val inputs = AttachmentsPreviewNode.Inputs( val inputs = AttachmentsPreviewNode.Inputs(
attachment = navTarget.attachment, attachments = navTarget.attachments,
timelineMode = navTarget.timelineMode, timelineMode = navTarget.timelineMode,
inReplyToEventId = navTarget.inReplyToEventId, inReplyToEventId = navTarget.inReplyToEventId,
) )
@@ -455,6 +514,18 @@ class MessagesFlowNode(
) )
} }
override fun handleGalleryItemClick(
event: TimelineItem.Event,
galleryItemIndex: Int,
canUseOverlay: Boolean,
) {
processGalleryEventClick(
event = event,
galleryItemIndex = galleryItemIndex,
canUseOverlay = canUseOverlay,
)
}
override fun navigateToRoomMemberDetails(userId: UserId) { override fun navigateToRoomMemberDetails(userId: UserId) {
callback.navigateToRoomMemberDetails(userId) callback.navigateToRoomMemberDetails(userId)
} }
@@ -490,7 +561,11 @@ class MessagesFlowNode(
focusedEventId = navTarget.focusedEventId, focusedEventId = navTarget.focusedEventId,
) )
val callback = object : ThreadedMessagesNode.Callback { 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( return processEventClick(
timelineMode = timelineMode, timelineMode = timelineMode,
event = event, event = event,
@@ -498,10 +573,22 @@ class MessagesFlowNode(
) )
} }
override fun handleGalleryItemClick(
event: TimelineItem.Event,
galleryItemIndex: Int,
canUseOverlay: Boolean,
): Boolean {
return processGalleryEventClick(
event = event,
canUseOverlay = canUseOverlay,
galleryItemIndex = galleryItemIndex,
)
}
override fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) { override fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) {
backstack.push( backstack.push(
NavTarget.AttachmentPreview( NavTarget.AttachmentPreview(
attachment = attachments.first(), attachments = attachments,
timelineMode = Timeline.Mode.Thread(navTarget.threadRootId), timelineMode = Timeline.Mode.Thread(navTarget.threadRootId),
inReplyToEventId = inReplyToEventId, inReplyToEventId = inReplyToEventId,
) )
@@ -704,6 +791,91 @@ class MessagesFlowNode(
} }
} }
private fun processGalleryEventClick(
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,
isAttachment = false,
)
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,
)
}
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,
isAttachment = true,
)
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,
)
}
else -> null
}
if (navTarget != null) {
if (canUseOverlay) {
overlay.show(navTarget)
} else {
backstack.push(navTarget)
}
return true
}
return false
}
private fun buildMediaViewerNavTarget( private fun buildMediaViewerNavTarget(
mode: MediaViewerEntryPoint.MediaViewerMode, mode: MediaViewerEntryPoint.MediaViewerMode,
event: TimelineItem.Event, event: TimelineItem.Event,
@@ -768,3 +940,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 { interface Callback : Plugin {
fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean
fun handleGalleryItemClick(event: TimelineItem.Event, galleryItemIndex: Int, canUseOverlay: Boolean): Boolean
fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
fun navigateToRoomMemberDetails(userId: UserId) fun navigateToRoomMemberDetails(userId: UserId)
fun handlePermalinkClick(data: PermalinkData) fun handlePermalinkClick(data: PermalinkData)
@@ -289,6 +290,9 @@ class MessagesNode(
} }
} }
}, },
onGalleryEventItemClick = { event, index ->
callback.handleGalleryItemClick(event, index, canUseOverlay)
},
onUserDataClick = callback::navigateToRoomMemberDetails, onUserDataClick = callback::navigateToRoomMemberDetails,
onLinkClick = { url, customTab -> onLinkClick = { url, customTab ->
onLinkClick( 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.components.receipt.bottomsheet.ReadReceiptBottomSheetState
import io.element.android.features.messages.impl.timeline.model.TimelineItem 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.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.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent 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.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.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter import io.element.android.features.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter
import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roomcall.api.RoomCallState
@@ -533,7 +533,7 @@ class MessagesPresenter(
) { ) {
val composerMode = MessageComposerMode.EditCaption( val composerMode = MessageComposerMode.EditCaption(
eventOrTransactionId = targetEvent.eventOrTransactionId, eventOrTransactionId = targetEvent.eventOrTransactionId,
content = (targetEvent.content as? TimelineItemEventContentWithAttachment)?.caption.orEmpty(), content = targetEvent.content.captionOrNull().orEmpty(),
) )
composerState.eventSink( composerState.eventSink(
MessageComposerEvent.SetMode(composerMode) MessageComposerEvent.SetMode(composerMode)
@@ -606,7 +606,7 @@ class MessagesPresenter(
} }
private fun handleCopyCaption(event: TimelineItem.Event) { private fun handleCopyCaption(event: TimelineItem.Event) {
val content = (event.content as? TimelineItemEventContentWithAttachment)?.caption ?: return val content = event.content.captionOrNull() ?: return
clipboardHelper.copyPlainText(content) clipboardHelper.copyPlainText(content)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
@@ -136,6 +136,7 @@ fun MessagesView(
onBackClick: () -> Unit, onBackClick: () -> Unit,
onRoomDetailsClick: () -> Unit, onRoomDetailsClick: () -> Unit,
onEventContentClick: (isLive: Boolean, event: TimelineItem.Event) -> Boolean, onEventContentClick: (isLive: Boolean, event: TimelineItem.Event) -> Boolean,
onGalleryEventItemClick: (event: TimelineItem.Event, index: Int) -> Boolean,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
onLinkClick: (String, Boolean) -> Unit, onLinkClick: (String, Boolean) -> Unit,
onSendLocationClick: () -> Unit, onSendLocationClick: () -> Unit,
@@ -207,15 +208,15 @@ fun MessagesView(
val expandableState = rememberExpandableBottomSheetLayoutState() val expandableState = rememberExpandableBottomSheetLayoutState()
ExpandableBottomSheetLayout( ExpandableBottomSheetLayout(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.imePadding() .imePadding()
.systemBarsPadding() .systemBarsPadding()
.onSizeChanged { size -> .onSizeChanged { size ->
// Let the composer takes at max half of the available height. // Let the composer takes at max half of the available height.
// The value will be different if the soft keyboard is displayed // The value will be different if the soft keyboard is displayed
// or not. // or not.
maxComposerHeightPx = (size.height * 0.5f).toInt() maxComposerHeightPx = (size.height * 0.5f).toInt()
}, },
content = { content = {
Scaffold( Scaffold(
contentWindowInsets = WindowInsets.statusBars, contentWindowInsets = WindowInsets.statusBars,
@@ -252,12 +253,18 @@ fun MessagesView(
content = { padding -> content = { padding ->
Box( Box(
modifier = Modifier modifier = Modifier
.padding(padding) .padding(padding)
.consumeWindowInsets(padding) .consumeWindowInsets(padding)
) { ) {
MessagesViewContent( MessagesViewContent(
state = state, state = state,
onContentClick = ::onContentClick, onContentClick = ::onContentClick,
onGalleryItemClick = { event, index ->
val hideKeyboard = onGalleryEventItemClick(event, index)
if (hideKeyboard) {
localView.hideKeyboard()
}
},
onMessageLongClick = ::onMessageLongClick, onMessageLongClick = ::onMessageLongClick,
onUserDataClick = { onUserDataClick = {
hidingKeyboard { hidingKeyboard {
@@ -290,10 +297,10 @@ fun MessagesView(
SuggestionsPickerView( SuggestionsPickerView(
modifier = Modifier modifier = Modifier
.shadow(10.dp) .shadow(10.dp)
.background(ElementTheme.colors.bgCanvasDefault) .background(ElementTheme.colors.bgCanvasDefault)
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.heightIn(max = 230.dp), .heightIn(max = 230.dp),
roomId = state.roomId, roomId = state.roomId,
roomName = state.roomName, roomName = state.roomName,
roomAvatarData = state.roomAvatar, roomAvatarData = state.roomAvatar,
@@ -459,6 +466,7 @@ private fun MessagesViewContent(
onMoreReactionsClick: (TimelineItem.Event) -> Unit, onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit, onReadReceiptClick: (TimelineItem.Event) -> Unit,
onMessageLongClick: (TimelineItem.Event) -> Unit, onMessageLongClick: (TimelineItem.Event) -> Unit,
onGalleryItemClick: ((TimelineItem.Event, Int) -> Unit),
onSendLocationClick: () -> Unit, onSendLocationClick: () -> Unit,
onCreatePollClick: () -> Unit, onCreatePollClick: () -> Unit,
onViewAllPinnedMessagesClick: () -> Unit, onViewAllPinnedMessagesClick: () -> Unit,
@@ -469,9 +477,9 @@ private fun MessagesViewContent(
) { ) {
Box( Box(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.navigationBarsPadding() .navigationBarsPadding()
.imePadding(), .imePadding(),
) { ) {
AttachmentsBottomSheet( AttachmentsBottomSheet(
state = state.composerState, state = state.composerState,
@@ -510,6 +518,7 @@ private fun MessagesViewContent(
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = { link -> onLinkClick(link, false) }, onLinkClick = { link -> onLinkClick(link, false) },
onContentClick = onContentClick, onContentClick = onContentClick,
onGalleryItemClick = onGalleryItemClick,
onMessageLongClick = onMessageLongClick, onMessageLongClick = onMessageLongClick,
onSwipeToReply = onSwipeToReply, onSwipeToReply = onSwipeToReply,
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
@@ -598,9 +607,9 @@ private fun MessagesViewComposerBottomSheetContents(
private fun CantSendMessageBanner() { private fun CantSendMessageBanner() {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(ElementTheme.colors.bgSubtleSecondary) .background(ElementTheme.colors.bgSubtleSecondary)
.padding(16.dp), .padding(16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
@@ -646,6 +655,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
forceJumpToBottomVisibility = true, forceJumpToBottomVisibility = true,
knockRequestsBannerView = {}, knockRequestsBannerView = {},
onThreadsListClick = {}, onThreadsListClick = {},
onGalleryEventItemClick = { _, _ -> false },
) )
} }
@@ -699,6 +709,7 @@ internal fun MessagesViewA11yPreview() = ElementPreview {
onJoinCallClick = {}, onJoinCallClick = {},
onViewAllPinnedMessagesClick = {}, onViewAllPinnedMessagesClick = {},
onThreadsListClick = {}, onThreadsListClick = {},
onGalleryEventItemClick = { _, _ -> false },
forceJumpToBottomVisibility = true, forceJumpToBottomVisibility = true,
knockRequestsBannerView = {}, knockRequestsBannerView = {},
) )
@@ -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.crypto.sendfailure.VerifiedUserSendFailureFactory
import io.element.android.features.messages.impl.timeline.model.TimelineItem 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.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.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment 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.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent 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.canBeCopied
import io.element.android.features.messages.impl.timeline.model.event.canBeForwarded 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.canReact
import io.element.android.features.messages.impl.timeline.model.event.captionOrNull
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode import io.element.android.libraries.dateformatter.api.DateFormatterMode
@@ -198,9 +201,12 @@ class DefaultActionListPresenter(
add(TimelineItemAction.Forward) add(TimelineItemAction.Forward)
} }
if (timelineItem.isEditable && usersEventPermissions.canSendMessage) { if (timelineItem.isEditable && usersEventPermissions.canSendMessage) {
if (timelineItem.content is TimelineItemEventContentWithAttachment) { if (timelineItem.content is TimelineItemEventContentWithAttachment ||
timelineItem.content is TimelineItemGalleryContent ||
timelineItem.content is TimelineItemAttachmentsContent) {
// Caption // Caption
if (timelineItem.content.caption == null) { val caption = timelineItem.content.captionOrNull()
if (caption == null) {
add(TimelineItemAction.AddCaption) add(TimelineItemAction.AddCaption)
} else { } else {
add(TimelineItemAction.EditCaption) add(TimelineItemAction.EditCaption)
@@ -225,7 +231,7 @@ class DefaultActionListPresenter(
} }
if (timelineItem.content.canBeCopied()) { if (timelineItem.content.canBeCopied()) {
add(TimelineItemAction.CopyText) add(TimelineItemAction.CopyText)
} else if ((timelineItem.content as? TimelineItemEventContentWithAttachment)?.caption.isNullOrBlank().not()) { } else if (timelineItem.content.captionOrNull().isNullOrBlank().not()) {
add(TimelineItemAction.CopyCaption) add(TimelineItemAction.CopyCaption)
} }
if (timelineItem.isRemote) { 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.a11y.a11yReactionAction
import io.element.android.features.messages.impl.timeline.components.MessageShieldView 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.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.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent 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.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.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@@ -299,6 +301,12 @@ private fun MessageSummary(
is TimelineItemImageContent -> { is TimelineItemImageContent -> {
content = { ContentForBody(event.content.bestDescription) } content = { ContentForBody(event.content.bestDescription) }
} }
is TimelineItemGalleryContent -> {
content = { ContentForBody(event.content.body) }
}
is TimelineItemAttachmentsContent -> {
content = { ContentForBody(event.content.body) }
}
is TimelineItemStickerContent -> { is TimelineItemStickerContent -> {
content = { ContentForBody(event.content.bestDescription) } content = { ContentForBody(event.content.bestDescription) }
} }
@@ -23,4 +23,5 @@ sealed interface AttachmentsPreviewEvent {
data object ResetImageEdits : AttachmentsPreviewEvent data object ResetImageEdits : AttachmentsPreviewEvent
data class UpdateImageCropRect(val cropRect: NormalizedCropRect) : AttachmentsPreviewEvent data class UpdateImageCropRect(val cropRect: NormalizedCropRect) : AttachmentsPreviewEvent
data object ClearImageEditError : 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.core.SessionId
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
import kotlinx.collections.immutable.ImmutableList
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
@AssistedInject @AssistedInject
@@ -42,7 +43,7 @@ class AttachmentsPreviewNode(
private val enterpriseService: EnterpriseService, private val enterpriseService: EnterpriseService,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
data class Inputs( data class Inputs(
val attachment: Attachment, val attachments: ImmutableList<Attachment>,
val timelineMode: Timeline.Mode, val timelineMode: Timeline.Mode,
val inReplyToEventId: EventId?, val inReplyToEventId: EventId?,
) : NodeInputs ) : NodeInputs
@@ -54,7 +55,7 @@ class AttachmentsPreviewNode(
} }
private val presenter = presenterFactory.create( private val presenter = presenterFactory.create(
attachment = inputs.attachment, attachments = inputs.attachments,
timelineMode = inputs.timelineMode, timelineMode = inputs.timelineMode,
onDoneListener = onDoneListener, onDoneListener = onDoneListener,
inReplyToEventId = inputs.inReplyToEventId, inReplyToEventId = inputs.inReplyToEventId,
@@ -12,6 +12,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -26,8 +27,6 @@ import io.element.android.features.messages.impl.attachments.preview.imageeditor
import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEditorState import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEditorState
import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEdits import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEdits
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorPresenter import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorPresenter
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
import io.element.android.features.messages.impl.attachments.video.VideoCompressionPresetSelector
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.androidutils.file.safeDelete import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.androidutils.hash.hash import io.element.android.libraries.androidutils.hash.hash
@@ -48,10 +47,11 @@ import io.element.android.libraries.mediaupload.api.allFiles
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState 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.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
@@ -59,7 +59,7 @@ import java.io.File
@AssistedInject @AssistedInject
class AttachmentsPreviewPresenter( class AttachmentsPreviewPresenter(
@Assisted private val attachment: Attachment, @Assisted private val attachments: ImmutableList<Attachment>,
@Assisted private val onDoneListener: OnDoneListener, @Assisted private val onDoneListener: OnDoneListener,
@Assisted private val timelineMode: Timeline.Mode, @Assisted private val timelineMode: Timeline.Mode,
@Assisted private val inReplyToEventId: EventId?, @Assisted private val inReplyToEventId: EventId?,
@@ -68,7 +68,6 @@ class AttachmentsPreviewPresenter(
private val temporaryUriDeleter: TemporaryUriDeleter, private val temporaryUriDeleter: TemporaryUriDeleter,
private val attachmentImageEditor: AttachmentImageEditor, private val attachmentImageEditor: AttachmentImageEditor,
private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory, private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory,
private val videoCompressionPresetSelector: VideoCompressionPresetSelector,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
@@ -76,13 +75,18 @@ class AttachmentsPreviewPresenter(
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create( fun create(
attachment: Attachment, attachments: ImmutableList<Attachment>,
timelineMode: Timeline.Mode, timelineMode: Timeline.Mode,
onDoneListener: OnDoneListener, onDoneListener: OnDoneListener,
inReplyToEventId: EventId?, inReplyToEventId: EventId?,
): AttachmentsPreviewPresenter ): AttachmentsPreviewPresenter
} }
data class AttachmentAndEdits(
val attachment: Attachment,
val edits: AttachmentImageEdits,
)
private val mediaSender = mediaSenderFactory.create(timelineMode) private val mediaSender = mediaSenderFactory.create(timelineMode)
@Composable @Composable
@@ -92,14 +96,13 @@ class AttachmentsPreviewPresenter(
val sendActionState = remember { val sendActionState = remember {
mutableStateOf<SendActionState>(SendActionState.Idle) mutableStateOf<SendActionState>(SendActionState.Idle)
} }
val originalLocalMedia = remember { (attachment as Attachment.Media).localMedia } val originalLocalMedia = remember { (attachments.first() as Attachment.Media).localMedia }
var currentAttachment by remember { mutableStateOf(attachment) } var currentAttachment by remember { mutableStateOf(attachments.first()) }
var canEditImage by remember { mutableStateOf(originalLocalMedia.info.canEditImage()) } var canEditImage by remember { mutableStateOf(originalLocalMedia.info.canEditImage()) }
var imageEditorState by remember { mutableStateOf<AttachmentImageEditorState?>(null) } var imageEditorState by remember { mutableStateOf<AttachmentImageEditorState?>(null) }
var appliedImageEdits by remember { mutableStateOf(AttachmentImageEdits()) }
var isApplyingImageEdits by remember { mutableStateOf(false) } var isApplyingImageEdits by remember { mutableStateOf(false) }
var displayImageEditError 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 markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false)
val textEditorState by rememberUpdatedState( val textEditorState by rememberUpdatedState(
@@ -108,13 +111,23 @@ class AttachmentsPreviewPresenter(
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) } val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
var currentIndex by remember { mutableIntStateOf(0) }
var attachmentsAndEdits by remember {
mutableStateOf(
attachments.map {
AttachmentAndEdits(it, AttachmentImageEdits())
}
)
}
var preprocessMediaJob by remember { mutableStateOf<Job?>(null) } var preprocessMediaJob by remember { mutableStateOf<Job?>(null) }
val mediaAttachment = currentAttachment as Attachment.Media val firstMediaAttachment = attachments.first() as Attachment.Media
val mediaOptimizationSelectorPresenter = remember { val mediaOptimizationSelectorPresenter = remember {
mediaOptimizationSelectorPresenterFactory.create( mediaOptimizationSelectorPresenterFactory.create(
localMedia = mediaAttachment.localMedia, localMedia = firstMediaAttachment.localMedia,
sendAsFile = mediaAttachment.sendAsFile, sendAsFile = firstMediaAttachment.sendAsFile,
) )
} }
val mediaOptimizationSelectorState by rememberUpdatedState(mediaOptimizationSelectorPresenter.present()) val mediaOptimizationSelectorState by rememberUpdatedState(mediaOptimizationSelectorPresenter.present())
@@ -123,52 +136,51 @@ class AttachmentsPreviewPresenter(
var displayFileTooLargeError by remember { mutableStateOf(false) } var displayFileTooLargeError by remember { mutableStateOf(false) }
LaunchedEffect( LaunchedEffect(mediaOptimizationSelectorState.displayMediaSelectorViews, mediaOptimizationSelectorState.selectedVideoPreset) {
mediaOptimizationSelectorState.displayMediaSelectorViews,
mediaOptimizationSelectorState.videoSizeEstimations,
currentAttachment,
imageEditorState,
isApplyingImageEdits,
) {
// 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 && if (mediaOptimizationSelectorState.displayMediaSelectorViews == false &&
preprocessMediaJob == null &&
imageEditorState == null && imageEditorState == null &&
!isApplyingImageEdits) { !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") val config = MediaOptimizationConfig(
return@LaunchedEffect compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled ?: mediaOptimizationConfigProvider.get().compressImages,
} videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: mediaOptimizationConfigProvider.get().videoCompressionPreset,
val config = getAutoPreprocessMediaOptimizationConfig(
mediaAttachment = mediaAttachment,
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
) ?: return@LaunchedEffect
preprocessMediaJob = coroutineScope.preProcessAttachment(
attachment = currentAttachment,
mediaOptimizationConfig = config,
displayProgress = false,
sendActionState = sendActionState,
) )
preprocessMediaJob?.cancel()
preprocessMediaJob = coroutineScope.launch(dispatchers.io) {
preProcessAttachments(
attachments = attachmentsAndEdits.map { it.attachment },
mediaOptimizationConfig = config,
displayProgress = false,
sendActionState = sendActionState,
)
}
} }
} }
LaunchedEffect(originalLocalMedia) { LaunchedEffect(currentIndex) {
canEditImage = originalLocalMedia.info.canEditImage() || attachmentImageEditor.canEdit(originalLocalMedia) 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 = mediaOptimizationSelectorState.maxUploadSize.dataOrNull()
LaunchedEffect(maxUploadSize) { LaunchedEffect(maxUploadSize) {
// Check file upload size if the media won't be processed for upload if (maxUploadSize != null) {
val isImageFile = mediaAttachment.localMedia.info.isImageAttachment()
val isVideoFile = mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo()
if (maxUploadSize != null && !(isImageFile || isVideoFile)) {
// If file size is not known, we're permissive and allow sending. The SDK will cancel the upload if needed. // 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 displayFileTooLargeError = attachments.any { attachment ->
if (maxUploadSize < fileSize) { when (attachment) {
displayFileTooLargeError = true 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
}
}
}
} }
} }
} }
@@ -192,12 +204,29 @@ class AttachmentsPreviewPresenter(
compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled == true, compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled == true,
videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: VideoCompressionPreset.STANDARD, videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: VideoCompressionPreset.STANDARD,
) )
preprocessMediaJob = preProcessAttachment( preprocessMediaJob = coroutineScope.launch(dispatchers.io) {
attachment = currentAttachment, preProcessAttachments(
mediaOptimizationConfig = config, attachments = attachmentsAndEdits.map { it.attachment },
displayProgress = true, mediaOptimizationConfig = config,
sendActionState = sendActionState, displayProgress = true,
sendActionState = sendActionState,
)
}
} else if (preprocessMediaJob?.isActive != true && sendActionState.value !is SendActionState.Sending.ReadyToUpload) {
val config = MediaOptimizationConfig(
compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled
?: mediaOptimizationConfigProvider.get().compressImages,
videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset
?: mediaOptimizationConfigProvider.get().videoCompressionPreset,
) )
preprocessMediaJob = coroutineScope.launch(dispatchers.io) {
preProcessAttachments(
attachments = attachmentsAndEdits.map { it.attachment },
mediaOptimizationConfig = config,
displayProgress = true,
sendActionState = sendActionState,
)
}
} }
// If the processing was hidden before, make it visible now // If the processing was hidden before, make it visible now
@@ -206,35 +235,27 @@ class AttachmentsPreviewPresenter(
} }
// Wait until the media is ready to be uploaded // 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 // Pre-processing is done, send the attachment
val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder) val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder)
.takeIf { it.isNotEmpty() } .takeIf { it.isNotEmpty() }
val editedTempFileToDelete = editedTempFile val editedTempFilesToDelete = editedTempFiles
editedTempFile = null editedTempFiles = emptyMap()
// If we're supposed to send the media as a background job, we can dismiss this screen already
if (coroutineContext.isActive) {
onDoneListener()
}
// Send the media using the session coroutine scope so it doesn't matter if this screen or the chat one are closed // 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) { sessionCoroutineScope.launch(dispatchers.io) {
try { sendGalleryPreProcessed(
sendPreProcessedMedia( mediaUploadInfos = allMediaUploadInfos,
mediaUploadInfo = mediaUploadInfo, caption = caption,
caption = caption, sendActionState = sendActionState,
sendActionState = sendActionState, inReplyToEventId = inReplyToEventId,
dismissAfterSend = false, )
inReplyToEventId = inReplyToEventId,
) // Clean up the pre-processed media after it's been sent
} finally { mediaSender.cleanUp()
editedTempFileToDelete?.safeDelete() editedTempFilesToDelete.values.forEach { it.safeDelete() }
// Clean up the pre-processed media after it's been sent
mediaSender.cleanUp()
}
} }
} }
} }
@@ -251,7 +272,11 @@ class AttachmentsPreviewPresenter(
ongoingSendAttachmentJob.value?.cancel() ongoingSendAttachmentJob.value?.cancel()
// Dismiss the screen // Dismiss the screen
dismiss(sendActionState, editedTempFile) dismiss(
attachments = attachmentsAndEdits.map { it.attachment },
sendActionState = sendActionState,
editedTempFiles = editedTempFiles,
)
} }
AttachmentsPreviewEvent.CancelAndClearSendState -> { AttachmentsPreviewEvent.CancelAndClearSendState -> {
// Cancel media sending // Cancel media sending
@@ -260,22 +285,23 @@ class AttachmentsPreviewPresenter(
ongoingSendAttachmentJob.value = null ongoingSendAttachmentJob.value = null
} }
val mediaUploadInfo = sendActionState.value.mediaUploadInfo() val mediaUploadInfoList = sendActionState.value.mediaUploadInfoList()
sendActionState.value = if (mediaUploadInfo != null) { sendActionState.value = if (mediaUploadInfoList != null) {
SendActionState.Sending.ReadyToUpload(mediaUploadInfo) SendActionState.Sending.ReadyToUpload(mediaUploadInfoList)
} else { } else {
SendActionState.Idle SendActionState.Idle
} }
} }
AttachmentsPreviewEvent.OpenImageEditor -> { 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) { if (resolvedCanEditImage) {
preprocessMediaJob?.cancel() preprocessMediaJob?.cancel()
preprocessMediaJob = null preprocessMediaJob = null
resetPreparedMedia(sendActionState) resetPreparedMedia(sendActionState)
imageEditorState = AttachmentImageEditorState( imageEditorState = AttachmentImageEditorState(
localMedia = originalLocalMedia, localMedia = currentLocalMedia,
edits = appliedImageEdits, edits = attachmentsAndEdits.get(currentIndex).edits,
previewDebug = false, previewDebug = false,
) )
} }
@@ -315,10 +341,15 @@ class AttachmentsPreviewPresenter(
AttachmentsPreviewEvent.ApplyImageEdits -> { AttachmentsPreviewEvent.ApplyImageEdits -> {
val pendingState = imageEditorState ?: return val pendingState = imageEditorState ?: return
if (!pendingState.edits.hasChanges) { if (!pendingState.edits.hasChanges) {
editedTempFile?.safeDelete() editedTempFiles[currentIndex]?.safeDelete()
editedTempFile = null editedTempFiles = editedTempFiles - currentIndex
appliedImageEdits = pendingState.edits currentAttachment = attachmentsAndEdits[currentIndex].attachment
currentAttachment = Attachment.Media(originalLocalMedia) attachmentsAndEdits = attachmentsAndEdits.toMutableList().also {
it[currentIndex] = AttachmentAndEdits(
currentAttachment,
pendingState.edits,
)
}.toImmutableList()
imageEditorState = null imageEditorState = null
resetPreparedMedia(sendActionState) resetPreparedMedia(sendActionState)
return return
@@ -328,16 +359,21 @@ class AttachmentsPreviewPresenter(
coroutineScope.launch { coroutineScope.launch {
val result = withContext(dispatchers.io) { val result = withContext(dispatchers.io) {
attachmentImageEditor.exportEdits( attachmentImageEditor.exportEdits(
localMedia = originalLocalMedia, localMedia = pendingState.localMedia,
edits = pendingState.edits, edits = pendingState.edits,
) )
} }
result.fold( result.fold(
onSuccess = { editedMedia -> onSuccess = { editedMedia ->
editedTempFile?.safeDelete() editedTempFiles[currentIndex]?.safeDelete()
editedTempFile = editedMedia.file editedTempFiles = editedTempFiles + (currentIndex to editedMedia.file)
appliedImageEdits = pendingState.edits
currentAttachment = Attachment.Media(editedMedia.localMedia) currentAttachment = Attachment.Media(editedMedia.localMedia)
attachmentsAndEdits = attachmentsAndEdits.toMutableList().also {
it[currentIndex] = AttachmentAndEdits(
currentAttachment,
pendingState.edits,
)
}.toImmutableList()
imageEditorState = null imageEditorState = null
resetPreparedMedia(sendActionState) resetPreparedMedia(sendActionState)
}, },
@@ -352,11 +388,14 @@ class AttachmentsPreviewPresenter(
AttachmentsPreviewEvent.ClearImageEditError -> { AttachmentsPreviewEvent.ClearImageEditError -> {
displayImageEditError = false displayImageEditError = false
} }
is AttachmentsPreviewEvent.SetCurrentCarouselIndex -> {
currentIndex = event.index
}
} }
} }
return AttachmentsPreviewState( return AttachmentsPreviewState(
attachment = currentAttachment, attachments = attachmentsAndEdits.map { it.attachment }.toImmutableList(),
imageEditorState = imageEditorState, imageEditorState = imageEditorState,
canEditImage = canEditImage, canEditImage = canEditImage,
isApplyingImageEdits = isApplyingImageEdits, isApplyingImageEdits = isApplyingImageEdits,
@@ -365,92 +404,62 @@ class AttachmentsPreviewPresenter(
textEditorState = textEditorState, textEditorState = textEditorState,
mediaOptimizationSelectorState = mediaOptimizationSelectorState, mediaOptimizationSelectorState = mediaOptimizationSelectorState,
displayFileTooLargeError = displayFileTooLargeError, displayFileTooLargeError = displayFileTooLargeError,
currentIndex = currentIndex,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
private suspend fun getAutoPreprocessMediaOptimizationConfig( private suspend fun preProcessAttachments(
mediaAttachment: Attachment.Media, attachments: List<Attachment>,
mediaOptimizationSelectorState: MediaOptimizationSelectorState,
): 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)
val videoCompressionPreset = videoCompressionPresetSelector.selectBestVideoPreset(
expectedVideoPreset = VideoCompressionPreset.HIGH,
videoSizeEstimations = mediaOptimizationSelectorState.videoSizeEstimations,
).dataOrNull() ?: VideoCompressionPreset.HIGH
MediaOptimizationConfig(
compressImages = false,
videoCompressionPreset = videoCompressionPreset,
)
} else {
// Otherwise, we just rely on the user preferences for media optimization
mediaOptimizationConfigProvider.get()
}
}
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, mediaOptimizationConfig: MediaOptimizationConfig,
displayProgress: Boolean, displayProgress: Boolean,
sendActionState: MutableState<SendActionState>, sendActionState: MutableState<SendActionState>,
) { ) {
sendActionState.value = SendActionState.Sending.Processing(displayProgress = displayProgress) sendActionState.value = SendActionState.Sending.Processing(displayProgress = displayProgress)
mediaSender.preProcessMedia( val mediaUploadInfos = mutableListOf<MediaUploadInfo>()
uri = mediaAttachment.localMedia.uri, for (attachment in attachments) {
mimeType = mediaAttachment.localMedia.info.mimeType, when (attachment) {
mediaOptimizationConfig = mediaOptimizationConfig, is Attachment.Media -> {
).fold( mediaSender.preProcessMedia(
onSuccess = { mediaUploadInfo -> uri = attachment.localMedia.uri,
Timber.d("Media ${mediaUploadInfo.file.path.orEmpty().hash()} finished processing, it's now ready to upload") mimeType = attachment.localMedia.info.mimeType,
sendActionState.value = SendActionState.Sending.ReadyToUpload(mediaUploadInfo) mediaOptimizationConfig = mediaOptimizationConfig,
}, ).fold(
onFailure = { onSuccess = { mediaUploadInfo ->
Timber.e(it, "Failed to pre-process media") Timber.d("Media ${mediaUploadInfo.file.path.orEmpty().hash()} finished processing")
if (it is CancellationException) { mediaUploadInfos.add(mediaUploadInfo)
throw it },
} else { onFailure = {
sendActionState.value = SendActionState.Failure(it, null) Timber.e(it, "Failed to pre-process media")
} if (it is CancellationException) {
} throw it
) } else {
} sendActionState.value = SendActionState.Failure(it, emptyList())
return
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)
} }
} }
} }
editedTempFile?.safeDelete() sendActionState.value = SendActionState.Sending.ReadyToUpload(mediaUploadInfos)
// Reset the sendActionState to ensure that dialog is closed before the screen }
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 sendActionState.value = SendActionState.Done
onDoneListener() onDoneListener()
} }
@@ -464,41 +473,45 @@ class AttachmentsPreviewPresenter(
} }
private fun resetPreparedMedia(sendActionState: MutableState<SendActionState>) { private fun resetPreparedMedia(sendActionState: MutableState<SendActionState>) {
sendActionState.value.mediaUploadInfo()?.let(::cleanUp) sendActionState.value.mediaUploadInfoList()?.forEach(::cleanUp)
mediaSender.cleanUp() mediaSender.cleanUp()
sendActionState.value = SendActionState.Idle sendActionState.value = SendActionState.Idle
} }
private suspend fun sendPreProcessedMedia( private suspend fun sendGalleryPreProcessed(
mediaUploadInfo: MediaUploadInfo, mediaUploadInfos: List<MediaUploadInfo>,
caption: String?, caption: String?,
sendActionState: MutableState<SendActionState>, sendActionState: MutableState<SendActionState>,
dismissAfterSend: Boolean,
inReplyToEventId: EventId?, inReplyToEventId: EventId?,
) = runCatchingExceptions { ) = runCatchingExceptions {
sendActionState.value = SendActionState.Sending.Uploading(mediaUploadInfo) if (mediaUploadInfos.size == 1) {
mediaSender.sendPreProcessedMedia( sendActionState.value = SendActionState.Sending.Uploading(mediaUploadInfos)
mediaUploadInfo = mediaUploadInfo, mediaSender.sendPreProcessedMedia(
caption = caption, mediaUploadInfo = mediaUploadInfos.first(),
formattedCaption = null, caption = caption,
inReplyToEventId = inReplyToEventId, formattedCaption = null,
).getOrThrow() inReplyToEventId = inReplyToEventId,
).getOrThrow()
} else {
mediaSender.sendGallery(
mediaUploadInfos = mediaUploadInfos,
caption = caption,
formattedCaption = null,
inReplyToEventId = inReplyToEventId,
).getOrThrow()
}
}.fold( }.fold(
onSuccess = { onSuccess = {
cleanUp(mediaUploadInfo) mediaUploadInfos.forEach { cleanUp(it) }
// Reset the sendActionState to ensure that dialog is closed before the screen
sendActionState.value = SendActionState.Done sendActionState.value = SendActionState.Done
onDoneListener()
if (dismissAfterSend) {
onDoneListener()
}
}, },
onFailure = { error -> onFailure = { error ->
Timber.e(error, "Failed to send attachment") Timber.e(error, "Failed to send attachment")
if (error is CancellationException) { if (error is CancellationException) {
throw error throw error
} else { } 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.features.messages.impl.attachments.video.MediaOptimizationSelectorState
import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.textcomposer.model.TextEditorState
import kotlinx.collections.immutable.ImmutableList
data class AttachmentsPreviewState( data class AttachmentsPreviewState(
val attachment: Attachment, val attachments: ImmutableList<Attachment>,
val imageEditorState: AttachmentImageEditorState?, val imageEditorState: AttachmentImageEditorState?,
val canEditImage: Boolean, val canEditImage: Boolean,
val isApplyingImageEdits: Boolean, val isApplyingImageEdits: Boolean,
@@ -25,8 +26,12 @@ data class AttachmentsPreviewState(
val textEditorState: TextEditorState, val textEditorState: TextEditorState,
val mediaOptimizationSelectorState: MediaOptimizationSelectorState, val mediaOptimizationSelectorState: MediaOptimizationSelectorState,
val displayFileTooLargeError: Boolean, val displayFileTooLargeError: Boolean,
val currentIndex: Int,
val eventSink: (AttachmentsPreviewEvent) -> Unit, val eventSink: (AttachmentsPreviewEvent) -> Unit,
) ) {
val isGallery: Boolean get() = attachments.size > 1
val totalCount: Int get() = attachments.size
}
@Immutable @Immutable
sealed interface SendActionState { sealed interface SendActionState {
@@ -35,17 +40,17 @@ sealed interface SendActionState {
@Immutable @Immutable
sealed interface Sending : SendActionState { sealed interface Sending : SendActionState {
data class Processing(val displayProgress: Boolean) : Sending data class Processing(val displayProgress: Boolean) : Sending
data class ReadyToUpload(val mediaInfo: MediaUploadInfo) : Sending data class ReadyToUpload(val mediaInfos: List<MediaUploadInfo>) : Sending
data class Uploading(val mediaUploadInfo: 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 data object Done : SendActionState
fun mediaUploadInfo(): MediaUploadInfo? = when (this) { fun mediaUploadInfoList(): List<MediaUploadInfo>? = when (this) {
is Sending.ReadyToUpload -> mediaInfo is Sending.ReadyToUpload -> mediaInfos
is Sending.Uploading -> mediaUploadInfo is Sending.Uploading -> mediaInfos
is Failure -> mediaUploadInfo is Failure -> mediaInfos
else -> null else -> null
} }
} }
@@ -41,9 +41,9 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider<Attachment
) )
), ),
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Processing(displayProgress = true)), anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Processing(displayProgress = true)),
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.ReadyToUpload(aMediaUploadInfo())), anAttachmentsPreviewState(sendActionState = SendActionState.Sending.ReadyToUpload(listOf(aMediaUploadInfo()))),
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(aMediaUploadInfo())), anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(listOf(aMediaUploadInfo()))),
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"), aMediaUploadInfo())), anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"), listOf(aMediaUploadInfo()))),
anAttachmentsPreviewState( anAttachmentsPreviewState(
imageEditorState = anAttachmentImageEditorState(), imageEditorState = anAttachmentImageEditorState(),
), ),
@@ -72,9 +72,12 @@ fun anAttachmentsPreviewState(
imageEditorState: AttachmentImageEditorState? = null, imageEditorState: AttachmentImageEditorState? = null,
mediaOptimizationSelectorState: MediaOptimizationSelectorState = aMediaOptimisationSelectorState(), mediaOptimizationSelectorState: MediaOptimizationSelectorState = aMediaOptimisationSelectorState(),
displayFileTooLargeError: Boolean = false, displayFileTooLargeError: Boolean = false,
currentIndex: Int = 0,
) = AttachmentsPreviewState( ) = AttachmentsPreviewState(
attachment = Attachment.Media( attachments = persistentListOf(
localMedia = LocalMedia("file://path".toUri(), mediaInfo), Attachment.Media(
localMedia = LocalMedia("file://path".toUri(), mediaInfo),
),
), ),
imageEditorState = imageEditorState, imageEditorState = imageEditorState,
canEditImage = true, canEditImage = true,
@@ -84,6 +87,37 @@ fun anAttachmentsPreviewState(
textEditorState = textEditorState, textEditorState = textEditorState,
mediaOptimizationSelectorState = mediaOptimizationSelectorState, mediaOptimizationSelectorState = mediaOptimizationSelectorState,
displayFileTooLargeError = displayFileTooLargeError, 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 = {} eventSink = {}
) )
@@ -9,11 +9,16 @@
package io.element.android.features.messages.impl.attachments.preview package io.element.android.features.messages.impl.attachments.preview
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
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.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -21,12 +26,17 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding 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.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@@ -62,9 +72,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.IconButton
import io.element.android.libraries.designsystem.theme.components.ListItem 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.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.Switch
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.theme.floatingDateBadgeBackground
import io.element.android.libraries.designsystem.utils.CommonDrawables 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.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
@@ -77,6 +89,20 @@ import io.element.android.libraries.ui.utils.formatter.rememberFileSizeFormatter
import io.element.android.wysiwyg.display.TextDisplay import io.element.android.wysiwyg.display.TextDisplay
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlin.time.Duration.Companion.milliseconds
private val SingleItemPreviewRenderer = object : LocalMediaRenderer {
@Composable
override fun Render(localMedia: LocalMedia) {
Image(
painter = painterResource(id = CommonDrawables.sample_background),
modifier = Modifier.fillMaxSize(),
contentDescription = null,
)
}
}
/** /**
* Ref: https://www.figma.com/design/zftpgS6LjiczobJZ1GUNpt/Updates-to-Media---File-Upload?node-id=51-3514 * Ref: https://www.figma.com/design/zftpgS6LjiczobJZ1GUNpt/Updates-to-Media---File-Upload?node-id=51-3514
@@ -252,6 +278,7 @@ private fun AttachmentSendStateView(
} }
} }
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
private fun AttachmentPreviewContent( private fun AttachmentPreviewContent(
state: AttachmentsPreviewState, state: AttachmentsPreviewState,
@@ -266,16 +293,75 @@ private fun AttachmentPreviewContent(
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.weight(1f), .fillMaxSize()
contentAlignment = Alignment.Center .weight(1f)
) { ) {
when (val attachment = state.attachment) { if (state.isGallery) {
is Attachment.Media -> { val pagerState = rememberPagerState(
localMediaRenderer.Render(attachment.localMedia) 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.first() as? Attachment.Media)?.localMedia?.info
if (mediaInfo?.isImageAttachment() == true) { if (mediaInfo?.isImageAttachment() == true) {
ImageOptimizationSelector(state.mediaOptimizationSelectorState) ImageOptimizationSelector(state.mediaOptimizationSelectorState)
} else if (mediaInfo?.mimeType?.isMimeTypeVideo() == true) { } else if (mediaInfo?.mimeType?.isMimeTypeVideo() == true) {
@@ -485,16 +571,16 @@ private fun AttachmentsPreviewBottomActions(
internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = ElementPreviewDark { internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = ElementPreviewDark {
AttachmentsPreviewView( AttachmentsPreviewView(
state = state, state = state,
localMediaRenderer = object : LocalMediaRenderer { localMediaRenderer = SingleItemPreviewRenderer,
@Composable )
override fun Render(localMedia: LocalMedia) { }
Image(
painter = painterResource(id = CommonDrawables.sample_background), @Preview
modifier = Modifier.fillMaxSize(), @Composable
contentDescription = null, internal fun AttachmentsPreviewGalleryViewPreview() = ElementPreviewDark {
) AttachmentsPreviewView(
} state = anAttachmentsPreviewGalleryState(),
} localMediaRenderer = SingleItemPreviewRenderer,
) )
} }
@@ -537,3 +623,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,
)
}
}
@@ -43,5 +43,6 @@ internal fun MessagesViewWithIdentityChangePreview(
onViewAllPinnedMessagesClick = {}, onViewAllPinnedMessagesClick = {},
knockRequestsBannerView = {}, knockRequestsBannerView = {},
onThreadsListClick = {}, onThreadsListClick = {},
onGalleryEventItemClick = { _, _ -> false },
) )
} }
@@ -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.location.api.LocationService
import io.element.android.features.messages.impl.MessagesNavigator 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
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.attachments.preview.error.sendAttachmentError
import io.element.android.features.messages.impl.draft.ComposerDraftService import io.element.android.features.messages.impl.draft.ComposerDraftService
import io.element.android.features.messages.impl.messagecomposer.suggestions.RoomAliasSuggestionsDataSource 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.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.di.annotations.SessionCoroutineScope 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.EventId
import io.element.android.libraries.matrix.api.core.ThreadId 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.core.UserId
@@ -133,6 +134,7 @@ class MessageComposerPresenter(
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
private val notificationConversationService: NotificationConversationService, private val notificationConversationService: NotificationConversationService,
private val slashCommandService: SlashCommandService, private val slashCommandService: SlashCommandService,
private val featureFlagService: FeatureFlagService,
) : Presenter<MessageComposerState> { ) : Presenter<MessageComposerState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@@ -177,10 +179,19 @@ class MessageComposerPresenter(
canShareLocation.value = locationService.isServiceAvailable() canShareLocation.value = locationService.isServiceAvailable()
} }
val isSendGalleryMessagesEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.SendGalleryMessages)
.collectAsState(initial = false)
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType -> val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType ->
handlePickedMedia(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) handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream, sendAsFile = true)
} }
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri -> val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
@@ -266,7 +277,7 @@ class MessageComposerPresenter(
is MessageComposerEvent.SendUri -> { is MessageComposerEvent.SendUri -> {
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
sessionCoroutineScope.sendAttachment( sessionCoroutineScope.sendAttachment(
attachment = Media( attachment = Attachment.Media(
localMedia = localMediaFactory.createFromUri( localMedia = localMediaFactory.createFromUri(
uri = event.uri, uri = event.uri,
mimeType = null, mimeType = null,
@@ -289,11 +300,19 @@ class MessageComposerPresenter(
MessageComposerEvent.DismissAttachmentMenu -> showAttachmentSourcePicker = false MessageComposerEvent.DismissAttachmentMenu -> showAttachmentSourcePicker = false
MessageComposerEvent.PickAttachmentSource.FromGallery -> localCoroutineScope.launch { MessageComposerEvent.PickAttachmentSource.FromGallery -> localCoroutineScope.launch {
showAttachmentSourcePicker = false showAttachmentSourcePicker = false
galleryMediaPicker.launch() if (isSendGalleryMessagesEnabled) {
galleryMultiMediaPicker.launch()
} else {
galleryMediaPicker.launch()
}
} }
MessageComposerEvent.PickAttachmentSource.FromFiles -> localCoroutineScope.launch { MessageComposerEvent.PickAttachmentSource.FromFiles -> localCoroutineScope.launch {
showAttachmentSourcePicker = false showAttachmentSourcePicker = false
filesPicker.launch() if (isSendGalleryMessagesEnabled) {
filesPicker.launch()
} else {
fileSinglePicker.launch()
}
} }
MessageComposerEvent.PickAttachmentSource.PhotoFromCamera -> localCoroutineScope.launch { MessageComposerEvent.PickAttachmentSource.PhotoFromCamera -> localCoroutineScope.launch {
showAttachmentSourcePicker = false showAttachmentSourcePicker = false
@@ -623,6 +642,30 @@ class MessageComposerPresenter(
messageComposerContext.composerMode = MessageComposerMode.Normal 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( private suspend fun sendMedia(
uri: Uri, uri: Uri,
mimeType: String, mimeType: String,
@@ -53,6 +53,7 @@ class PinnedMessagesListNode(
) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator { ) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator {
interface Callback : Plugin { interface Callback : Plugin {
fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean) fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean)
fun handleGalleryItemClick(event: TimelineItem.Event, galleryItemIndex: Int, canUseOverlay: Boolean)
fun navigateToRoomMemberDetails(userId: UserId) fun navigateToRoomMemberDetails(userId: UserId)
fun viewInTimeline(eventId: EventId) fun viewInTimeline(eventId: EventId)
fun handlePermalinkClick(data: PermalinkData.RoomLink) fun handlePermalinkClick(data: PermalinkData.RoomLink)
@@ -119,6 +120,9 @@ class PinnedMessagesListNode(
onEventClick = { onEventClick = {
callback.handleEventClick(it, canUseOverlay) callback.handleEventClick(it, canUseOverlay)
}, },
onGalleryItemClick = { event, index ->
callback.handleGalleryItemClick(event, index, canUseOverlay)
},
onUserDataClick = { callback.navigateToRoomMemberDetails(it.userId) }, onUserDataClick = { callback.navigateToRoomMemberDetails(it.userId) },
onLinkClick = { link -> onLinkClick(context, link.url) }, onLinkClick = { link -> onLinkClick(context, link.url) },
onLinkLongClick = { onLinkLongClick = {
@@ -60,6 +60,7 @@ fun PinnedMessagesListView(
state: PinnedMessagesListState, state: PinnedMessagesListState,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onEventClick: (event: TimelineItem.Event) -> Unit, onEventClick: (event: TimelineItem.Event) -> Unit,
onGalleryItemClick: (event: TimelineItem.Event, index: Int) -> Unit,
onUserDataClick: (MatrixUser) -> Unit, onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit, onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit,
@@ -81,6 +82,7 @@ fun PinnedMessagesListView(
PinnedMessagesListContent( PinnedMessagesListContent(
state = state, state = state,
onEventClick = onEventClick, onEventClick = onEventClick,
onGalleryItemClick = onGalleryItemClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick, onLinkLongClick = onLinkLongClick,
@@ -111,6 +113,7 @@ private fun PinnedMessagesListTopBar(
private fun PinnedMessagesListContent( private fun PinnedMessagesListContent(
state: PinnedMessagesListState, state: PinnedMessagesListState,
onEventClick: (event: TimelineItem.Event) -> Unit, onEventClick: (event: TimelineItem.Event) -> Unit,
onGalleryItemClick: (event: TimelineItem.Event, index: Int) -> Unit,
onUserDataClick: (MatrixUser) -> Unit, onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit, onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit,
@@ -131,6 +134,7 @@ private fun PinnedMessagesListContent(
state = state, state = state,
displayThreadSummaries = state.displayThreadSummaries, displayThreadSummaries = state.displayThreadSummaries,
onEventClick = onEventClick, onEventClick = onEventClick,
onGalleryItemClick = onGalleryItemClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick, onLinkLongClick = onLinkLongClick,
@@ -169,6 +173,7 @@ private fun PinnedMessagesListLoaded(
state: PinnedMessagesListState.Filled, state: PinnedMessagesListState.Filled,
displayThreadSummaries: Boolean, displayThreadSummaries: Boolean,
onEventClick: (event: TimelineItem.Event) -> Unit, onEventClick: (event: TimelineItem.Event) -> Unit,
onGalleryItemClick: (event: TimelineItem.Event, index: Int) -> Unit,
onUserDataClick: (MatrixUser) -> Unit, onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit, onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit,
@@ -226,6 +231,7 @@ private fun PinnedMessagesListLoaded(
}, },
onLinkLongClick = onLinkLongClick, onLinkLongClick = onLinkLongClick,
onContentClick = onEventClick, onContentClick = onEventClick,
onGalleryItemClick = onGalleryItemClick,
onLongClick = ::onMessageLongClick, onLongClick = ::onMessageLongClick,
displayThreadSummaries = displayThreadSummaries, displayThreadSummaries = displayThreadSummaries,
inReplyToClick = {}, inReplyToClick = {},
@@ -245,6 +251,7 @@ private fun PinnedMessagesListLoaded(
event = event, event = event,
timelineProtectionState = state.timelineProtectionState, timelineProtectionState = state.timelineProtectionState,
onContentClick = { onEventClick(event) }, onContentClick = { onEventClick(event) },
onGalleryItemClick = { index -> onGalleryItemClick(event, index) },
onLongClick = { onMessageLongClick(event) }, onLongClick = { onMessageLongClick(event) },
onLinkClick = { link -> onLinkClick = { link ->
state.linkState.eventSink(LinkEvent.OnLinkClick(link)) state.linkState.eventSink(LinkEvent.OnLinkClick(link))
@@ -268,6 +275,7 @@ private fun TimelineItemEventContentViewWrapper(
event: TimelineItem.Event, event: TimelineItem.Event,
timelineProtectionState: TimelineProtectionState, timelineProtectionState: TimelineProtectionState,
onContentClick: () -> Unit, onContentClick: () -> Unit,
onGalleryItemClick: (index: Int) -> Unit,
onLinkClick: (Link) -> Unit, onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit,
onLongClick: (() -> Unit)?, onLongClick: (() -> Unit)?,
@@ -285,6 +293,7 @@ private fun TimelineItemEventContentViewWrapper(
content = event.content, content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine), hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onGalleryItemClick = onGalleryItemClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick, onLinkLongClick = onLinkLongClick,
eventSink = { }, eventSink = { },
@@ -304,6 +313,7 @@ internal fun PinnedMessagesListViewPreview(@PreviewParameter(PinnedMessagesListS
state = state, state = state,
onBackClick = {}, onBackClick = {},
onEventClick = { }, onEventClick = { },
onGalleryItemClick = { _, _ -> },
onUserDataClick = {}, onUserDataClick = {},
onLinkClick = {}, onLinkClick = {},
onLinkLongClick = {}, onLinkLongClick = {},
@@ -126,6 +126,7 @@ class ThreadedMessagesNode(
interface Callback : Plugin { interface Callback : Plugin {
fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean
fun handleGalleryItemClick(event: TimelineItem.Event, galleryItemIndex: Int, canUseOverlay: Boolean): Boolean
fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
fun navigateToRoomMemberDetails(userId: UserId) fun navigateToRoomMemberDetails(userId: UserId)
fun handlePermalinkClick(data: PermalinkData) fun handlePermalinkClick(data: PermalinkData)
@@ -289,6 +290,9 @@ class ThreadedMessagesNode(
} }
} == true } == true
}, },
onGalleryEventItemClick = { event, index ->
callback.handleGalleryItemClick(event, index, canUseOverlay)
},
onUserDataClick = callback::navigateToRoomMemberDetails, onUserDataClick = callback::navigateToRoomMemberDetails,
onLinkClick = { url, customTab -> onLinkClick = { url, customTab ->
onLinkClick( onLinkClick(
@@ -97,6 +97,7 @@ fun TimelineView(
onUserDataClick: (MatrixUser) -> Unit, onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit, onLinkClick: (Link) -> Unit,
onContentClick: (TimelineItem.Event) -> Unit, onContentClick: (TimelineItem.Event) -> Unit,
onGalleryItemClick: ((TimelineItem.Event, Int) -> Unit),
onMessageLongClick: (TimelineItem.Event) -> Unit, onMessageLongClick: (TimelineItem.Event) -> Unit,
onSwipeToReply: (TimelineItem.Event) -> Unit, onSwipeToReply: (TimelineItem.Event) -> Unit,
onReactionClick: (emoji: String, TimelineItem.Event) -> Unit, onReactionClick: (emoji: String, TimelineItem.Event) -> Unit,
@@ -178,6 +179,7 @@ fun TimelineView(
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onLinkLongClick = ::onLinkLongClick, onLinkLongClick = ::onLinkLongClick,
onContentClick = onContentClick, onContentClick = onContentClick,
onGalleryItemClick = onGalleryItemClick,
onLongClick = onMessageLongClick, onLongClick = onMessageLongClick,
inReplyToClick = ::inReplyToClick, inReplyToClick = ::inReplyToClick,
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
@@ -428,6 +430,7 @@ internal fun TimelineViewPreview(
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onReadReceiptClick = {}, onReadReceiptClick = {},
onGalleryItemClick = { _, _ -> },
forceJumpToBottomVisibility = true, forceJumpToBottomVisibility = true,
) )
} }
@@ -49,6 +49,7 @@ internal fun TimelineViewMessageShieldPreview() = ElementPreview {
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onReadReceiptClick = {}, onReadReceiptClick = {},
onGalleryItemClick = { _, _ -> },
forceJumpToBottomVisibility = true, forceJumpToBottomVisibility = true,
) )
} }
@@ -33,6 +33,7 @@ internal fun ATimelineItemEventRow(
isLastOutgoingMessage = isLastOutgoingMessage, isLastOutgoingMessage = isLastOutgoingMessage,
displayThreadSummaries = displayThreadSummaries, displayThreadSummaries = displayThreadSummaries,
onEventClick = {}, onEventClick = {},
onGalleryItemClick = {},
onLongClick = {}, onLongClick = {},
onLinkClick = {}, onLinkClick = {},
onLinkLongClick = {}, 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.model.event.GalleryItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.aGalleryItem
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemGalleryContent
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",
),
),
),
)
}
@@ -70,6 +70,8 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo 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.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.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
@@ -145,6 +147,7 @@ fun TimelineItemEventRow(
isLastOutgoingMessage: Boolean, isLastOutgoingMessage: Boolean,
displayThreadSummaries: Boolean, displayThreadSummaries: Boolean,
onEventClick: () -> Unit, onEventClick: () -> Unit,
onGalleryItemClick: ((Int) -> Unit),
onLongClick: () -> Unit, onLongClick: () -> Unit,
onLinkClick: (Link) -> Unit, onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit,
@@ -165,13 +168,14 @@ fun TimelineItemEventRow(
content = event.content, content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId), hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
onContentClick = onContentClick, onContentClick = onContentClick,
onGalleryItemClick = onGalleryItemClick,
onLongClick = onLongClick, onLongClick = onLongClick,
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick, onLinkLongClick = onLinkLongClick,
eventSink = eventSink, eventSink = eventSink,
modifier = contentModifier, modifier = contentModifier,
onContentLayoutChange = onContentLayoutChange onContentLayoutChange = onContentLayoutChange,
) )
}, },
) { ) {
@@ -776,6 +780,8 @@ private fun MessageEventBubbleContent(
val timestampPosition = when (val content = event.content) { val timestampPosition = when (val content = event.content) {
is TimelineItemImageContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay is TimelineItemImageContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
is TimelineItemVideoContent -> 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 TimelineItemStickerContent -> TimestampPosition.Overlay
is TimelineItemLocationContent -> { is TimelineItemLocationContent -> {
val content = content.ensureActiveLiveLocation() val content = content.ensureActiveLiveLocation()
@@ -790,6 +796,8 @@ private fun MessageEventBubbleContent(
val paddingBehaviour = when (event.content) { val paddingBehaviour = when (event.content) {
is TimelineItemImageContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media is TimelineItemImageContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
is TimelineItemVideoContent -> 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 TimelineItemStickerContent,
is TimelineItemLocationContent -> ContentPadding.Media is TimelineItemLocationContent -> ContentPadding.Media
else -> ContentPadding.Textual 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, eventSink = eventSink,
modifier = contentModifier, modifier = contentModifier,
onContentClick = null, onContentClick = null,
onGalleryItemClick = {},
onLongClick = null, onLongClick = null,
onContentLayoutChange = onContentLayoutChange onContentLayoutChange = onContentLayoutChange
) )
@@ -140,6 +141,7 @@ private fun TimelineItemGroupedEventsRowContent(
eventSink = eventSink, eventSink = eventSink,
modifier = contentModifier, modifier = contentModifier,
onContentClick = null, onContentClick = null,
onGalleryItemClick = {},
onLongClick = null, onLongClick = null,
onContentLayoutChange = onContentLayoutChange onContentLayoutChange = onContentLayoutChange
) )
@@ -177,6 +179,7 @@ private fun TimelineItemGroupedEventsRowContent(
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick, onLinkLongClick = onLinkLongClick,
onContentClick = onClick, onContentClick = onClick,
onGalleryItemClick = { _, _ -> },
onLongClick = onLongClick, onLongClick = onLongClick,
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
@@ -64,6 +64,7 @@ internal fun TimelineItemRow(
onLinkClick: (Link) -> Unit, onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit,
onContentClick: (TimelineItem.Event) -> Unit, onContentClick: (TimelineItem.Event) -> Unit,
onGalleryItemClick: (TimelineItem.Event, Int) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit,
@@ -80,12 +81,13 @@ internal fun TimelineItemRow(
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine), hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onContentClick = { onContentClick(event) }, onContentClick = { onContentClick(event) },
onGalleryItemClick = { index -> onGalleryItemClick(event, index) },
onLongClick = { onLongClick(event) }, onLongClick = { onLongClick(event) },
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick, onLinkLongClick = onLinkLongClick,
eventSink = eventSink, eventSink = eventSink,
modifier = contentModifier, modifier = contentModifier,
onContentLayoutChange = onContentLayoutChange onContentLayoutChange = onContentLayoutChange,
) )
}, },
) { ) {
@@ -178,6 +180,7 @@ internal fun TimelineItemRow(
onMoreReactionsClick = onMoreReactionsClick, onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick, onReadReceiptClick = onReadReceiptClick,
onSwipeToReply = { onSwipeToReply(timelineItem) }, onSwipeToReply = { onSwipeToReply(timelineItem) },
onGalleryItemClick = { index -> onGalleryItemClick(timelineItem, index) },
eventSink = eventSink, eventSink = eventSink,
eventContentView = { contentModifier, onContentLayoutChange -> eventContentView = { contentModifier, onContentLayoutChange ->
eventContentView(timelineItem, contentModifier, onContentLayoutChange) eventContentView(timelineItem, contentModifier, onContentLayoutChange)
@@ -74,6 +74,7 @@ fun TimelineItemStateEventRow(
onShowContentClick = {}, onShowContentClick = {},
eventSink = eventSink, eventSink = eventSink,
onContentClick = null, onContentClick = null,
onGalleryItemClick = {},
onLongClick = null, onLongClick = null,
modifier = Modifier.defaultTimelineContentPadding() modifier = Modifier.defaultTimelineContentPadding()
) )
@@ -0,0 +1,68 @@
/*
* 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 = "photo.jpg",
mimeType = "image/jpeg",
thumbnailSource = MediaSource(url = "thumb", json = ""),
fileSize = null,
formattedFileSize = "1.2 MB",
fileExtension = "JPG",
),
anAttachmentItem(
filename = "spreadsheet.xlsx",
mimeType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
fileSize = null,
formattedFileSize = "450 KB",
fileExtension = "XLSX",
),
),
),
aTimelineItemAttachmentsContent(
body = "Files",
caption = "Important documents",
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",
),
),
),
)
}
@@ -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.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories 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.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.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent 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.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent 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.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@@ -40,6 +42,7 @@ fun TimelineItemEventContentView(
content: TimelineItemEventContent, content: TimelineItemEventContent,
hideMediaContent: Boolean, hideMediaContent: Boolean,
onContentClick: (() -> Unit)?, onContentClick: (() -> Unit)?,
onGalleryItemClick: ((Int) -> Unit),
onLongClick: (() -> Unit)?, onLongClick: (() -> Unit)?,
onShowContentClick: () -> Unit, onShowContentClick: () -> Unit,
onLinkClick: (Link) -> Unit, onLinkClick: (Link) -> Unit,
@@ -90,6 +93,23 @@ fun TimelineItemEventContentView(
onContentLayoutChange = onContentLayoutChange, onContentLayoutChange = onContentLayoutChange,
modifier = modifier, 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( is TimelineItemStickerContent -> TimelineItemStickerView(
content = content, content = content,
hideMediaContent = hideMediaContent, hideMediaContent = hideMediaContent,
@@ -0,0 +1,115 @@
/*
* 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.features.messages.impl.timeline.model.event.aTimelineItemGalleryContent
import io.element.android.libraries.matrix.api.media.MediaSource
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,
)
},
),
)
}
private fun aGalleryItem(
type: GalleryItem.Type = GalleryItem.Type.Image,
width: Int = 400,
height: Int = 300,
duration: Duration = Duration.ZERO,
): GalleryItem {
return GalleryItem(
filename = "photo.jpg",
mimeType = "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,14 +14,19 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -31,13 +36,13 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.hideFromAccessibility import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
@@ -56,7 +61,6 @@ import io.element.android.features.messages.impl.timeline.protection.ProtectedVi
import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.modifiers.roundedBackground
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT
@@ -65,6 +69,7 @@ import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.libraries.ui.utils.time.formatShort
import io.element.android.wysiwyg.compose.EditorStyledText import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link import io.element.android.wysiwyg.link.Link
@@ -79,6 +84,7 @@ fun TimelineItemVideoView(
onLinkLongClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isGalleryVideo: Boolean = false,
) { ) {
val isTalkbackActive = isTalkbackActive() val isTalkbackActive = isTalkbackActive()
val a11yLabel = stringResource(CommonStrings.common_video) val a11yLabel = stringResource(CommonStrings.common_video)
@@ -130,16 +136,71 @@ fun TimelineItemVideoView(
onState = { isLoaded = it is AsyncImagePainter.State.Success }, onState = { isLoaded = it is AsyncImagePainter.State.Success },
) )
Box( if (isGalleryVideo) {
modifier = Modifier.roundedBackground(), Box(modifier = Modifier.fillMaxSize()) {
contentAlignment = Alignment.Center, Box(
) { modifier = Modifier
Image( .fillMaxSize()
imageVector = CompoundIcons.PlaySolid(), .align(Alignment.BottomCenter)
contentDescription = stringResource(id = CommonStrings.a11y_play), .background(
colorFilter = ColorFilter.tint(Color.White), brush = Brush.verticalGradient(
modifier = Modifier.semantics { hideFromAccessibility() } colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.6f))
) )
)
)
Row(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center
) {
Image(
imageVector = CompoundIcons.VideoCallSolid(),
contentDescription = null,
colorFilter = ColorFilter.tint(Color.White),
modifier = Modifier.size(16.dp)
)
}
Spacer(modifier = Modifier.weight(1f))
Box(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(Color.Black.copy(alpha = 0.5f))
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
Text(
text = content.duration.formatShort(),
color = Color.White,
style = ElementTheme.typography.fontBodyXsRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
} else {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.6f)),
contentAlignment = Alignment.Center,
) {
Image(
imageVector = CompoundIcons.PlaySolid(),
contentDescription = stringResource(id = CommonStrings.a11y_play),
colorFilter = ColorFilter.tint(Color.White),
modifier = Modifier.size(24.dp)
)
}
} }
} }
} }
@@ -184,6 +245,7 @@ internal fun TimelineItemVideoViewPreview(@PreviewParameter(TimelineItemVideoCon
onLinkClick = {}, onLinkClick = {},
onLinkLongClick = {}, onLinkLongClick = {},
onContentLayoutChange = {}, onContentLayoutChange = {},
isGalleryVideo = false,
) )
} }
@@ -199,6 +261,7 @@ internal fun TimelineItemVideoViewHideMediaContentPreview() = ElementPreview {
onLinkClick = {}, onLinkClick = {},
onLinkLongClick = {}, onLinkLongClick = {},
onContentLayoutChange = {}, onContentLayoutChange = {},
isGalleryVideo = false,
) )
} }
@@ -14,10 +14,14 @@ import androidx.core.text.toSpannable
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.location.api.Location import io.element.android.features.location.api.Location
import io.element.android.features.messages.api.timeline.HtmlConverterProvider 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.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent 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.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent 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.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent 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.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType 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.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.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
@@ -263,6 +269,144 @@ class TimelineItemContentMessageFactory(
isEdited = content.isEdited, 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 isMediaGallery = galleryItems.all { item ->
item.type.isMedia()
}
if (isMediaGallery && 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 -> { is OtherMessageType -> {
val body = messageType.body.trimEnd() val body = messageType.body.trimEnd()
TimelineItemTextContent( TimelineItemTextContent(
@@ -9,9 +9,11 @@
package io.element.android.features.messages.impl.timeline.groups 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.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.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent 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.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.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@@ -52,6 +54,8 @@ internal fun TimelineItem.Event.canBeGrouped(): Boolean {
is TimelineItemTextBasedContent, is TimelineItemTextBasedContent,
is TimelineItemEncryptedContent, is TimelineItemEncryptedContent,
is TimelineItemImageContent, is TimelineItemImageContent,
is TimelineItemGalleryContent,
is TimelineItemAttachmentsContent,
is TimelineItemStickerContent, is TimelineItemStickerContent,
is TimelineItemFileContent, is TimelineItemFileContent,
is TimelineItemVideoContent, is TimelineItemVideoContent,
@@ -10,7 +10,9 @@ package io.element.android.features.messages.impl.timeline.model
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.timeline.components.MessageShieldData 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.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.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent 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 TimelineItemStickerContent -> content.formattedCaption == null && content.caption == null
is TimelineItemImageContent -> content.formattedCaption == null && content.caption == null is TimelineItemImageContent -> content.formattedCaption == null && content.caption == null
is TimelineItemVideoContent -> content.formattedCaption == null && content.caption == null is TimelineItemVideoContent -> content.formattedCaption == null && content.caption == null
is TimelineItemGalleryContent -> false
is TimelineItemAttachmentsContent -> false
else -> true 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 TimelineItemAudioContent,
is TimelineItemVideoContent, is TimelineItemVideoContent,
is TimelineItemLocationContent, 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 // Stickers can't be forwarded (yet) so we don't show the option
// See https://github.com/element-hq/element-x-android/issues/2161 // See https://github.com/element-hq/element-x-android/issues/2161
is TimelineItemStickerContent -> false is TimelineItemStickerContent -> false
@@ -78,7 +80,9 @@ fun TimelineItemEventContent.canReact(): Boolean =
is TimelineItemLocationContent, is TimelineItemLocationContent,
is TimelineItemPollContent, is TimelineItemPollContent,
is TimelineItemVoiceContent, is TimelineItemVoiceContent,
is TimelineItemVideoContent -> true is TimelineItemVideoContent,
is TimelineItemGalleryContent,
is TimelineItemAttachmentsContent -> true
is TimelineItemStateContent, is TimelineItemStateContent,
is TimelineItemRedactedContent, is TimelineItemRedactedContent,
is TimelineItemLegacyCallInviteContent, is TimelineItemLegacyCallInviteContent,
@@ -99,6 +103,18 @@ fun TimelineItemEventContent.isEdited(): Boolean = when (this) {
*/ */
fun TimelineItemEventContent.isRedacted(): Boolean = this is TimelineItemRedactedContent 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? { fun TimelineItemEventContentWithAttachment.duration(): Duration? {
return when (this) { return when (this) {
is TimelineItemAudioContent -> duration is TimelineItemAudioContent -> duration
@@ -13,8 +13,11 @@ import android.text.style.StyleSpan
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.core.text.buildSpannedString import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans 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 io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import kotlinx.collections.immutable.toImmutableList
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import kotlin.time.Duration
class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEventContent> { class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEventContent> {
override val values = sequenceOf( override val values = sequenceOf(
@@ -111,3 +114,81 @@ fun aTimelineItemStateEventContent(
) = TimelineItemStateEventContent( ) = TimelineItemStateEventContent(
body = body, body = body,
) )
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",
width: Int = 400,
height: Int = 300,
type: GalleryItem.Type = GalleryItem.Type.Image,
duration: Duration = Duration.ZERO,
) = 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 = ""),
type = type,
thumbnailSource = null,
width = width,
height = height,
thumbnailWidth = width,
thumbnailHeight = height,
blurhash = null,
duration = duration,
)
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 isMedia() = 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 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.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.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent 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.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent 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.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@@ -40,6 +42,8 @@ fun TimelineItem.mustBeProtected(): Boolean {
} else { } else {
when (content) { when (content) {
is TimelineItemImageContent, is TimelineItemImageContent,
is TimelineItemGalleryContent,
is TimelineItemAttachmentsContent,
is TimelineItemVideoContent, is TimelineItemVideoContent,
is TimelineItemStickerContent -> true is TimelineItemStickerContent -> true
is TimelineItemAudioContent, is TimelineItemAudioContent,
@@ -11,10 +11,12 @@ package io.element.android.features.messages.impl.utils.messagesummary
import android.content.Context import android.content.Context
import dev.zacsweers.metro.ContributesBinding 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.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.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent 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.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent 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.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent 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 TimelineItemVideoContent -> context.getString(CommonStrings.common_video)
is TimelineItemFileContent -> context.getString(CommonStrings.common_file) is TimelineItemFileContent -> context.getString(CommonStrings.common_file)
is TimelineItemAudioContent -> context.getString(CommonStrings.common_audio) 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 TimelineItemLegacyCallInviteContent -> context.getString(CommonStrings.common_unsupported_call)
is TimelineItemRtcNotificationContent -> when (content.state) { is TimelineItemRtcNotificationContent -> when (content.state) {
is RtcNotificationState.Declined -> { is RtcNotificationState.Declined -> {
@@ -685,6 +685,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setMessagesView(
onBackClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(),
onRoomDetailsClick: () -> Unit = EnsureNeverCalled(), onRoomDetailsClick: () -> Unit = EnsureNeverCalled(),
onEventClick: (isLive: Boolean, event: TimelineItem.Event) -> Boolean = EnsureNeverCalledWithTwoParamsAndResult(), onEventClick: (isLive: Boolean, event: TimelineItem.Event) -> Boolean = EnsureNeverCalledWithTwoParamsAndResult(),
onGalleryEventItemClick: (event: TimelineItem.Event, index: Int) -> Boolean = EnsureNeverCalledWithTwoParamsAndResult(),
onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(), onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
onLinkClick: (String, Boolean) -> Unit = EnsureNeverCalledWithTwoParams(), onLinkClick: (String, Boolean) -> Unit = EnsureNeverCalledWithTwoParams(),
onSendLocationClick: () -> Unit = EnsureNeverCalled(), onSendLocationClick: () -> Unit = EnsureNeverCalled(),
@@ -701,6 +702,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setMessagesView(
onBackClick = onBackClick, onBackClick = onBackClick,
onRoomDetailsClick = onRoomDetailsClick, onRoomDetailsClick = onRoomDetailsClick,
onEventContentClick = onEventClick, onEventContentClick = onEventClick,
onGalleryEventItemClick = onGalleryEventItemClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onSendLocationClick = onSendLocationClick, onSendLocationClick = onSendLocationClick,
@@ -23,7 +23,6 @@ import io.element.android.features.messages.impl.attachments.preview.imageeditor
import io.element.android.features.messages.impl.attachments.preview.imageeditor.NormalizedCropRect import io.element.android.features.messages.impl.attachments.preview.imageeditor.NormalizedCropRect
import io.element.android.features.messages.impl.attachments.preview.imageeditor.assertIsSimilarTo import io.element.android.features.messages.impl.attachments.preview.imageeditor.assertIsSimilarTo
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
import io.element.android.features.messages.impl.attachments.video.VideoCompressionPresetSelector
import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation
import io.element.android.features.messages.impl.fixtures.aMediaAttachment import io.element.android.features.messages.impl.fixtures.aMediaAttachment
import io.element.android.features.messages.test.attachments.video.FakeMediaOptimizationSelectorPresenterFactory import io.element.android.features.messages.test.attachments.video.FakeMediaOptimizationSelectorPresenterFactory
@@ -33,6 +32,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.core.EventId
import io.element.android.libraries.matrix.api.media.AudioInfo 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.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.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
@@ -70,6 +70,7 @@ import io.element.android.tests.testutils.testCoroutineDispatchers
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
@@ -121,8 +122,8 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false))
initialState.eventSink(AttachmentsPreviewEvent.SendAttachment) initialState.eventSink(AttachmentsPreviewEvent.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = true)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = true))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo)))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(listOf(mediaUploadInfo)))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
sendFileResult.assertions().isCalledOnce() sendFileResult.assertions().isCalledOnce()
onDoneListener.assertions().isCalledOnce() onDoneListener.assertions().isCalledOnce()
@@ -157,8 +158,8 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
advanceUntilIdle() advanceUntilIdle()
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false))
initialState.eventSink(AttachmentsPreviewEvent.SendAttachment) initialState.eventSink(AttachmentsPreviewEvent.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo)))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(listOf(mediaUploadInfo)))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
sendFileResult.assertions().isCalledOnce() sendFileResult.assertions().isCalledOnce()
onDoneListener.assertions().isCalledOnce() onDoneListener.assertions().isCalledOnce()
@@ -193,8 +194,8 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
// Pre-processing finishes // Pre-processing finishes
processLatch.complete(Unit) processLatch.complete(Unit)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = true)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = true))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo)))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(listOf(mediaUploadInfo)))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
sendFileResult.assertions().isCalledOnce() sendFileResult.assertions().isCalledOnce()
onDoneListener.assertions().isCalledOnce() onDoneListener.assertions().isCalledOnce()
@@ -411,18 +412,14 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.eventSink(AttachmentsPreviewEvent.SendAttachment) initialState.eventSink(AttachmentsPreviewEvent.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo)))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(listOf(mediaUploadInfo)))
// Check that the onDoneListener is called so the screen would be dismissed
onDoneListenerResult.assertions().isCalledOnce()
val failureState = awaitItem() val failureState = awaitItem()
assertThat(failureState.sendActionState).isEqualTo(SendActionState.Failure(failure, mediaUploadInfo)) assertThat(failureState.sendActionState).isEqualTo(SendActionState.Failure(failure, listOf(mediaUploadInfo)))
sendFileResult.assertions().isCalledOnce() sendFileResult.assertions().isCalledOnce()
failureState.eventSink(AttachmentsPreviewEvent.CancelAndClearSendState) failureState.eventSink(AttachmentsPreviewEvent.CancelAndClearSendState)
val clearedState = awaitLastSequentialItem() val clearedState = awaitLastSequentialItem()
assertThat(clearedState.sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) assertThat(clearedState.sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo)))
} }
} }
@@ -444,15 +441,12 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.eventSink(AttachmentsPreviewEvent.SendAttachment) initialState.eventSink(AttachmentsPreviewEvent.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo)))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(listOf(mediaUploadInfo)))
initialState.eventSink(AttachmentsPreviewEvent.CancelAndClearSendState) 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 // The sending is cancelled and the state is kept at ReadyToUpload
ensureAllEventsConsumed() ensureAllEventsConsumed()
// Check that the onDoneListener is called so the screen would be dismissed
onDoneListenerResult.assertions().isCalledOnce()
} }
} }
@@ -464,7 +458,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
val maxUploadSize = 999L // Set a max upload size smaller than the file size val maxUploadSize = 999L // Set a max upload size smaller than the file size
val presenter = createAttachmentsPreviewPresenter( val presenter = createAttachmentsPreviewPresenter(
localMedia = localMedia, attachments = listOf(Attachment.Media(localMedia)),
room = FakeJoinedRoom( room = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply { liveTimeline = FakeTimeline().apply {
sendFileLambda = { _, _, _, _, _ -> sendFileLambda = { _, _, _, _, _ ->
@@ -503,7 +497,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
val localMedia = aLocalMedia(uri = Uri.EMPTY, mediaInfo = aVideoMediaInfo()) val localMedia = aLocalMedia(uri = Uri.EMPTY, mediaInfo = aVideoMediaInfo())
val presenter = createAttachmentsPreviewPresenter( val presenter = createAttachmentsPreviewPresenter(
localMedia = localMedia, attachments = listOf(Attachment.Media(localMedia)),
room = FakeJoinedRoom( room = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply { liveTimeline = FakeTimeline().apply {
sendFileLambda = { _, _, _, _, _ -> sendFileLambda = { _, _, _, _, _ ->
@@ -584,7 +578,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
assertThat(awaitItem().isApplyingImageEdits).isTrue() assertThat(awaitItem().isApplyingImageEdits).isTrue()
val appliedState = awaitItem() 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.imageEditorState).isNull()
assertThat(appliedState.isApplyingImageEdits).isFalse() assertThat(appliedState.isApplyingImageEdits).isFalse()
} }
@@ -601,7 +595,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
bottom = 0.9f, bottom = 0.9f,
) )
val presenter = createAttachmentsPreviewPresenter( val presenter = createAttachmentsPreviewPresenter(
localMedia = originalLocalMedia, attachments = listOf(Attachment.Media(originalLocalMedia)),
displayMediaQualitySelectorViews = true, displayMediaQualitySelectorViews = true,
attachmentImageEditor = FakeAttachmentImageEditor { attachmentImageEditor = FakeAttachmentImageEditor {
Result.success( Result.success(
@@ -629,7 +623,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
flippedState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits) flippedState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits)
val appliedState = consumeItemsUntilPredicate { !it.isApplyingImageEdits && it.imageEditorState == null }.last() 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) appliedState.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
val reopenedState = consumeItemsUntilPredicate { it.imageEditorState != null }.last() val reopenedState = consumeItemsUntilPredicate { it.imageEditorState != null }.last()
@@ -673,21 +667,26 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
} }
} }
@Test
fun `present - sendAsFile attachment is pre-processed without image compression`() = runTest { fun `present - sendAsFile attachment is pre-processed without image compression`() = runTest {
// Even though the user has enabled "Optimize media quality" globally, picking the file // Even though the user has enabled "Optimize media quality" globally, picking the file
// through the Files picker (sendAsFile = true) must skip compression. Regression test // through the Files picker (sendAsFile = true) must skip compression. Regression test
// for https://github.com/element-hq/element-x-android/issues/6365 // for https://github.com/element-hq/element-x-android/issues/6365
val mediaPreProcessor = FakeMediaPreProcessor() val mediaPreProcessor = FakeMediaPreProcessor()
val presenter = createAttachmentsPreviewPresenter( val presenter = createAttachmentsPreviewPresenter(
localMedia = aLocalMedia(mockMediaUrl, anImageMediaInfo()), attachments = listOf(
sendAsFile = true, Attachment.Media(
localMedia = aLocalMedia(mockMediaUrl, anImageMediaInfo()),
sendAsFile = true,
)
),
mediaPreProcessor = mediaPreProcessor, mediaPreProcessor = mediaPreProcessor,
// Selector views are hidden in the sendAsFile flow, which triggers the auto pre-process path. // Selector views are hidden in the sendAsFile flow, which triggers the auto pre-process path.
displayMediaQualitySelectorViews = false, displayMediaQualitySelectorViews = false,
mediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider( mediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(
config = MediaOptimizationConfig( config = MediaOptimizationConfig(
compressImages = true, compressImages = false,
videoCompressionPreset = VideoCompressionPreset.STANDARD, videoCompressionPreset = VideoCompressionPreset.HIGH,
) )
), ),
) )
@@ -772,8 +771,9 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
fileExtension = "png", fileExtension = "png",
), ),
) )
val presenter = createAttachmentsPreviewPresenter(localMedia = localMedia) val presenter = createAttachmentsPreviewPresenter(
attachments = listOf(Attachment.Media(localMedia)),
)
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.canEditImage).isTrue() assertThat(initialState.canEditImage).isTrue()
@@ -795,7 +795,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
), ),
) )
val presenter = createAttachmentsPreviewPresenter( val presenter = createAttachmentsPreviewPresenter(
localMedia = localMedia, attachments = listOf(Attachment.Media(localMedia)),
attachmentImageEditor = FakeAttachmentImageEditor( attachmentImageEditor = FakeAttachmentImageEditor(
canEditResult = true, canEditResult = true,
) { ) {
@@ -822,8 +822,12 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
fun `present - sendAsFile video is pre-processed with best fitting preset`() = runTest { fun `present - sendAsFile video is pre-processed with best fitting preset`() = runTest {
val mediaPreProcessor = FakeMediaPreProcessor() val mediaPreProcessor = FakeMediaPreProcessor()
val presenter = createAttachmentsPreviewPresenter( val presenter = createAttachmentsPreviewPresenter(
localMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()), attachments = listOf(
sendAsFile = true, Attachment.Media(
localMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()),
sendAsFile = true,
)
),
mediaPreProcessor = mediaPreProcessor, mediaPreProcessor = mediaPreProcessor,
// Selector views are hidden in the sendAsFile flow, which triggers the auto pre-process path. // Selector views are hidden in the sendAsFile flow, which triggers the auto pre-process path.
displayMediaQualitySelectorViews = false, displayMediaQualitySelectorViews = false,
@@ -863,11 +867,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( private fun TestScope.createAttachmentsPreviewPresenter(
localMedia: LocalMedia = aLocalMedia( attachments: List<Attachment> = listOf(
uri = mockMediaUrl, aMediaAttachment(
aLocalMedia(
uri = mockMediaUrl,
)
),
), ),
sendAsFile: Boolean = false,
room: JoinedRoom = FakeJoinedRoom(), room: JoinedRoom = FakeJoinedRoom(),
timelineMode: Timeline.Mode = Timeline.Mode.Live, timelineMode: Timeline.Mode = Timeline.Mode.Live,
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(), permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
@@ -890,17 +945,21 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
), ),
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
attachmentImageEditor: AttachmentImageEditor = FakeAttachmentImageEditor { attachmentImageEditor: AttachmentImageEditor = FakeAttachmentImageEditor {
Result.success( val localMediaResult = (attachments.first() as? Attachment.Media)?.localMedia?.copy(uri = Uri.parse("file:///tmp/default-edited.jpeg"))
EditedLocalMedia( if (localMediaResult != null) {
localMedia = localMedia.copy(uri = Uri.parse("file:///tmp/default-edited.jpeg")), Result.success(
file = File("/tmp/default-edited.jpeg"), EditedLocalMedia(
localMedia = localMediaResult,
file = File("/tmp/default-edited.jpeg"),
)
) )
) } else {
Result.failure(IllegalStateException("Check test values"))
}
}, },
videoCompressionPresetSelector: VideoCompressionPresetSelector = VideoCompressionPresetSelector(),
): AttachmentsPreviewPresenter { ): AttachmentsPreviewPresenter {
return AttachmentsPreviewPresenter( return AttachmentsPreviewPresenter(
attachment = aMediaAttachment(localMedia, sendAsFile = sendAsFile), attachments = attachments.toImmutableList(),
onDoneListener = onDoneListener, onDoneListener = onDoneListener,
mediaSenderFactory = MediaSenderFactory { timelineMode -> mediaSenderFactory = MediaSenderFactory { timelineMode ->
DefaultMediaSender( DefaultMediaSender(
@@ -918,7 +977,6 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
sessionCoroutineScope = this, sessionCoroutineScope = this,
dispatchers = testCoroutineDispatchers(), dispatchers = testCoroutineDispatchers(),
mediaOptimizationSelectorPresenterFactory = mediaOptimizationSelectorPresenterFactory, mediaOptimizationSelectorPresenterFactory = mediaOptimizationSelectorPresenterFactory,
videoCompressionPresetSelector = videoCompressionPresetSelector,
timelineMode = timelineMode, timelineMode = timelineMode,
inReplyToEventId = null, inReplyToEventId = null,
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
@@ -11,28 +11,27 @@ package io.element.android.features.messages.impl.attachments
import com.google.common.truth.Truth.assertThat 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.SendActionState
import io.element.android.features.messages.impl.attachments.preview.aMediaUploadInfo import io.element.android.features.messages.impl.attachments.preview.aMediaUploadInfo
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import org.junit.Test import org.junit.Test
class SendActionStateTest { class SendActionStateTest {
@Test @Test
fun `mediaUploadInfo() should return the value from Uploading class`() { fun `mediaUploadInfoList() should return the value from Uploading class`() {
val mediaUploadInfo: MediaUploadInfo = aMediaUploadInfo() val data = listOf(aMediaUploadInfo())
val state: SendActionState = SendActionState.Sending.Uploading(mediaUploadInfo = aMediaUploadInfo()) val state: SendActionState = SendActionState.Sending.Uploading(mediaInfos = data)
assertThat(state.mediaUploadInfo()).isEqualTo(mediaUploadInfo) assertThat(state.mediaUploadInfoList()).isEqualTo(data)
} }
@Test @Test
fun `mediaUploadInfo() should return the value from ReadyToUpload class`() { fun `mediaUploadInfoList() should return the value from ReadyToUpload class`() {
val mediaUploadInfo: MediaUploadInfo = aMediaUploadInfo() val data = listOf(aMediaUploadInfo())
val state: SendActionState = SendActionState.Sending.ReadyToUpload(mediaInfo = aMediaUploadInfo()) val state: SendActionState = SendActionState.Sending.ReadyToUpload(mediaInfos = data)
assertThat(state.mediaUploadInfo()).isEqualTo(mediaUploadInfo) assertThat(state.mediaUploadInfoList()).isEqualTo(data)
} }
@Test @Test
fun `mediaUploadInfo() should return the value from Failure class`() { fun `mediaUploadInfoList() should return the value from Failure class`() {
val mediaUploadInfo: MediaUploadInfo = aMediaUploadInfo() val data = listOf(aMediaUploadInfo())
val state: SendActionState = SendActionState.Failure(error = IllegalStateException("An error"), mediaUploadInfo = aMediaUploadInfo()) val state: SendActionState = SendActionState.Failure(error = IllegalStateException("An error"), mediaInfos = data)
assertThat(state.mediaUploadInfo()).isEqualTo(mediaUploadInfo) assertThat(state.mediaUploadInfoList()).isEqualTo(data)
} }
} }
@@ -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.FakeTextPillificationHelper
import io.element.android.features.messages.impl.utils.TextPillificationHelper import io.element.android.features.messages.impl.utils.TextPillificationHelper
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher 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.ThreadId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
@@ -274,6 +275,7 @@ class MessageComposerPresenterSlashCommandTest {
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
threadRoot: ThreadId? = null, threadRoot: ThreadId? = null,
slashCommandService: SlashCommandService = FakeSlashCommandService(), slashCommandService: SlashCommandService = FakeSlashCommandService(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
) = MessageComposerPresenter( ) = MessageComposerPresenter(
navigator = navigator, navigator = navigator,
sessionCoroutineScope = this, sessionCoroutineScope = this,
@@ -312,6 +314,7 @@ class MessageComposerPresenterSlashCommandTest {
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
notificationConversationService = notificationConversationService, notificationConversationService = notificationConversationService,
slashCommandService = slashCommandService, slashCommandService = slashCommandService,
featureFlagService = featureFlagService,
).apply { ).apply {
isTesting = true isTesting = true
showTextFormatting = isRichTextEditorEnabled 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.architecture.AsyncAction
import io.element.android.libraries.core.mimetype.MimeTypes 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.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.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
@@ -1534,6 +1535,7 @@ class MessageComposerPresenterTest : RobolectricTest() {
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
threadRoot: ThreadId? = null, threadRoot: ThreadId? = null,
slashCommandService: SlashCommandService = FakeSlashCommandService(), slashCommandService: SlashCommandService = FakeSlashCommandService(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
) = MessageComposerPresenter( ) = MessageComposerPresenter(
navigator = navigator, navigator = navigator,
sessionCoroutineScope = this, sessionCoroutineScope = this,
@@ -1572,6 +1574,7 @@ class MessageComposerPresenterTest : RobolectricTest() {
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
notificationConversationService = notificationConversationService, notificationConversationService = notificationConversationService,
slashCommandService = slashCommandService, slashCommandService = slashCommandService,
featureFlagService = featureFlagService,
).apply { ).apply {
isTesting = true isTesting = true
showTextFormatting = isRichTextEditorEnabled 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.libraries.matrix.api.user.MatrixUser
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam 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.EventsRecorder
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam import io.element.android.tests.testutils.ensureCalledOnceWithParam
@@ -96,6 +97,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setPinnedMessagesListView(
state: PinnedMessagesListState, state: PinnedMessagesListState,
onBackClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(),
onEventClick: (event: TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), onEventClick: (event: TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
onGalleryItemClick: (event: TimelineItem.Event, index: Int) -> Unit = EnsureNeverCalledWithTwoParams(),
onUserDataClick: (MatrixUser) -> Unit = EnsureNeverCalledWithParam(), onUserDataClick: (MatrixUser) -> Unit = EnsureNeverCalledWithParam(),
onLinkClick: (Link) -> Unit = EnsureNeverCalledWithParam(), onLinkClick: (Link) -> Unit = EnsureNeverCalledWithParam(),
onLinkLongClick: (Link) -> Unit = EnsureNeverCalledWithParam(), onLinkLongClick: (Link) -> Unit = EnsureNeverCalledWithParam(),
@@ -105,6 +107,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setPinnedMessagesListView(
state = state, state = state,
onBackClick = onBackClick, onBackClick = onBackClick,
onEventClick = onEventClick, onEventClick = onEventClick,
onGalleryItemClick = onGalleryItemClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick, onLinkLongClick = onLinkLongClick,
@@ -216,6 +216,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setTimelineView(
onReactionLongClick: (emoji: String, TimelineItem.Event) -> Unit = EnsureNeverCalledWithTwoParams(), onReactionLongClick: (emoji: String, TimelineItem.Event) -> Unit = EnsureNeverCalledWithTwoParams(),
onMoreReactionsClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), onMoreReactionsClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
onReadReceiptClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), onReadReceiptClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
onGalleryItemClick: (TimelineItem.Event, Int) -> Unit = EnsureNeverCalledWithTwoParams(),
forceJumpToBottomVisibility: Boolean = false, forceJumpToBottomVisibility: Boolean = false,
) { ) {
setSafeContent(clearAndroidUiDispatcher = true) { setSafeContent(clearAndroidUiDispatcher = true) {
@@ -231,6 +232,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setTimelineView(
onReactionLongClick = onReactionLongClick, onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick, onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick, onReadReceiptClick = onReadReceiptClick,
onGalleryItemClick = onGalleryItemClick,
forceJumpToBottomVisibility = forceJumpToBottomVisibility, forceJumpToBottomVisibility = forceJumpToBottomVisibility,
) )
} }
@@ -5,7 +5,7 @@
<string name="screen_start_chat_error_starting_chat">"An error occurred when trying to start a chat"</string> <string name="screen_start_chat_error_starting_chat">"An error occurred when trying to start a chat"</string>
<string name="screen_start_chat_join_room_by_address_action">"Join room by address"</string> <string name="screen_start_chat_join_room_by_address_action">"Join room by address"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Not a valid address"</string> <string name="screen_start_chat_join_room_by_address_invalid_address">"Not a valid address"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Enter…"</string> <string name="screen_start_chat_join_room_by_address_placeholder">"Enter address…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Matching room found"</string> <string name="screen_start_chat_join_room_by_address_room_found">"Matching room found"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Room not found"</string> <string name="screen_start_chat_join_room_by_address_room_not_found">"Room not found"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"e.g. #room-name:matrix.org"</string> <string name="screen_start_chat_join_room_by_address_supporting_text">"e.g. #room-name:matrix.org"</string>
@@ -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.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem 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.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.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
@@ -104,6 +105,9 @@ class DefaultPinnedMessagesBannerFormatter(
is OtherMessageType -> { is OtherMessageType -> {
messageType.body messageType.body
} }
is GalleryMessageType -> {
messageType.body.prefixWith(CommonStrings.common_gallery)
}
is NoticeMessageType -> { is NoticeMessageType -> {
messageType.body 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.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent 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.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.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent
@@ -167,6 +168,9 @@ class DefaultRoomLatestEventFormatter(
is OtherMessageType -> { is OtherMessageType -> {
messageType.body messageType.body
} }
is GalleryMessageType -> {
messageType.body.prefixWith(sp.getString(CommonStrings.common_gallery))
}
is NoticeMessageType -> { is NoticeMessageType -> {
messageType.body 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.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent 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.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.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange 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), AudioMessageType(body, null, null, MediaSource("url"), null),
VoiceMessageType(body, null, null, MediaSource("url"), null, null), VoiceMessageType(body, null, null, MediaSource("url"), null, null),
ImageMessageType(body, null, null, MediaSource("url"), null), ImageMessageType(body, null, null, MediaSource("url"), null),
GalleryMessageType(body, null, emptyList()),
StickerMessageType(body, null, null, MediaSource("url"), null), StickerMessageType(body, null, null, MediaSource("url"), null),
FileMessageType(body, null, null, MediaSource("url"), null), FileMessageType(body, null, null, MediaSource("url"), null),
LocationMessageType(body, "geo:1,2", null, null), LocationMessageType(body, "geo:1,2", null, null),
@@ -163,6 +165,7 @@ class DefaultPinnedMessagesBannerFormatterTest : RobolectricTest() {
is VideoMessageType, is VideoMessageType,
is AudioMessageType, is AudioMessageType,
is ImageMessageType, is ImageMessageType,
is GalleryMessageType,
is StickerMessageType, is StickerMessageType,
is FileMessageType, is FileMessageType,
is LocationMessageType -> AnnotatedString::class.java is LocationMessageType -> AnnotatedString::class.java
@@ -181,6 +184,7 @@ class DefaultPinnedMessagesBannerFormatterTest : RobolectricTest() {
is AudioMessageType -> "Audio: Shared body" is AudioMessageType -> "Audio: Shared body"
is VoiceMessageType -> "Voice message" is VoiceMessageType -> "Voice message"
is ImageMessageType -> "Image: Shared body" is ImageMessageType -> "Image: Shared body"
is GalleryMessageType -> "Gallery: Shared body"
is StickerMessageType -> "Sticker: Shared body" is StickerMessageType -> "Sticker: Shared body"
is FileMessageType -> "File: Shared body" is FileMessageType -> "File: Shared body"
is LocationMessageType -> "Shared location: 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.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent 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.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.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange 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), AudioMessageType(body, null, null, MediaSource("url"), null),
VoiceMessageType(body, null, null, MediaSource("url"), null, null), VoiceMessageType(body, null, null, MediaSource("url"), null, null),
ImageMessageType(body, null, null, MediaSource("url"), null), ImageMessageType(body, null, null, MediaSource("url"), null),
GalleryMessageType(body, null, emptyList()),
StickerMessageType(body, null, null, MediaSource("url"), null), StickerMessageType(body, null, null, MediaSource("url"), null),
FileMessageType(body, null, null, MediaSource("url"), null), FileMessageType(body, null, null, MediaSource("url"), null),
LocationMessageType(body, "geo:1,2", null, null), LocationMessageType(body, "geo:1,2", null, null),
@@ -227,6 +229,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
is AudioMessageType -> "Audio: Shared body" is AudioMessageType -> "Audio: Shared body"
is VoiceMessageType -> "Voice message" is VoiceMessageType -> "Voice message"
is ImageMessageType -> "Image: Shared body" is ImageMessageType -> "Image: Shared body"
is GalleryMessageType -> "Gallery: Shared body"
is StickerMessageType -> "Sticker: Shared body" is StickerMessageType -> "Sticker: Shared body"
is FileMessageType -> "File: Shared body" is FileMessageType -> "File: Shared body"
is LocationMessageType -> "Shared location" is LocationMessageType -> "Shared location"
@@ -247,6 +250,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
is TextMessageType -> false is TextMessageType -> false
is NoticeMessageType -> false is NoticeMessageType -> false
is OtherMessageType -> false is OtherMessageType -> false
is GalleryMessageType -> true
} }
if (shouldCreateAnnotatedString) { if (shouldCreateAnnotatedString) {
assertWithMessage("$type doesn't produce an AnnotatedString") assertWithMessage("$type doesn't produce an AnnotatedString")
@@ -264,6 +268,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
is AudioMessageType -> "$expectedPrefix: Audio: Shared body" is AudioMessageType -> "$expectedPrefix: Audio: Shared body"
is VoiceMessageType -> "$expectedPrefix: Voice message" is VoiceMessageType -> "$expectedPrefix: Voice message"
is ImageMessageType -> "$expectedPrefix: Image: Shared body" is ImageMessageType -> "$expectedPrefix: Image: Shared body"
is GalleryMessageType -> "$expectedPrefix: Gallery: Shared body"
is StickerMessageType -> "$expectedPrefix: Sticker: Shared body" is StickerMessageType -> "$expectedPrefix: Sticker: Shared body"
is FileMessageType -> "$expectedPrefix: File: Shared body" is FileMessageType -> "$expectedPrefix: File: Shared body"
is LocationMessageType -> "$expectedPrefix: Shared location" is LocationMessageType -> "$expectedPrefix: Shared location"
@@ -284,6 +289,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
is TextMessageType -> true is TextMessageType -> true
is NoticeMessageType -> true is NoticeMessageType -> true
is OtherMessageType -> true is OtherMessageType -> true
is GalleryMessageType -> true
} }
if (shouldCreateAnnotatedString) { if (shouldCreateAnnotatedString) {
assertWithMessage("$type doesn't produce an AnnotatedString") assertWithMessage("$type doesn't produce an AnnotatedString")
@@ -137,4 +137,11 @@ enum class FeatureFlags(
defaultValue = { false }, defaultValue = { false },
isFinished = 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.core.TransactionId
import io.element.android.libraries.matrix.api.media.AudioInfo 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.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.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaUploadHandler import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.media.VideoInfo
@@ -157,6 +158,13 @@ interface Timeline : AutoCloseable {
inReplyToEventId: EventId?, inReplyToEventId: EventId?,
): Result<MediaUploadHandler> ): 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 redactEvent(eventOrTransactionId: EventOrTransactionId, reason: String?): Result<Unit>
suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result<Boolean> suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result<Boolean>
@@ -102,6 +102,21 @@ data class TextMessageType(
val formatted: FormattedBody? val formatted: FormattedBody?
) : MessageType ) : 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( data class OtherMessageType(
val msgType: String, val msgType: String,
val body: String, val body: String,
@@ -0,0 +1,24 @@
/*
* 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.timeline.item.event.FormattedBody
import org.matrix.rustcomponents.sdk.FormattedBody as RustFormattedBody
import org.matrix.rustcomponents.sdk.MessageFormat as RustMessageFormat
fun FormattedBody.map(): RustFormattedBody = RustFormattedBody(
format = format.map(),
body = body,
)
private fun io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat.map(): RustMessageFormat {
return when (this) {
io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat.HTML -> RustMessageFormat.Html
io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat.UNKNOWN -> RustMessageFormat.Unknown("")
}
}
@@ -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) is MessageLikeEventContent.ReactionContent -> NotificationContent.MessageLike.ReactionContent(relatedEventId)
MessageLikeEventContent.RoomEncrypted -> NotificationContent.MessageLike.RoomEncrypted MessageLikeEventContent.RoomEncrypted -> NotificationContent.MessageLike.RoomEncrypted
is MessageLikeEventContent.RoomMessage -> { is MessageLikeEventContent.RoomMessage -> {
NotificationContent.MessageLike.RoomMessage(senderId, EventMessageMapper().mapMessageType(messageType)) NotificationContent.MessageLike.RoomMessage(
senderId,
EventMessageMapper().mapMessageType(messageType)
)
} }
is MessageLikeEventContent.RoomRedaction -> NotificationContent.MessageLike.RoomRedaction( is MessageLikeEventContent.RoomRedaction -> NotificationContent.MessageLike.RoomRedaction(
redactedEventId = redactedEventId?.let(::EventId), redactedEventId = redactedEventId?.let(::EventId),
@@ -219,6 +219,7 @@ class JoinedRustRoom(
RoomMessageEventMessageType.IMAGE, RoomMessageEventMessageType.IMAGE,
RoomMessageEventMessageType.VIDEO, RoomMessageEventMessageType.VIDEO,
RoomMessageEventMessageType.AUDIO, RoomMessageEventMessageType.AUDIO,
RoomMessageEventMessageType.GALLERY,
) )
) )
is CreateTimelineParams.Focused, 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.core.UserId
import io.element.android.libraries.matrix.api.media.AudioInfo 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.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.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaUploadHandler import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.media.VideoInfo 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.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId 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.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.MediaUploadHandlerImpl
import io.element.android.libraries.matrix.impl.media.map import io.element.android.libraries.matrix.impl.media.map
import io.element.android.libraries.matrix.impl.poll.toInner import io.element.android.libraries.matrix.impl.poll.toInner
@@ -67,6 +69,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.EditedContent import org.matrix.rustcomponents.sdk.EditedContent
import org.matrix.rustcomponents.sdk.FormattedBody import org.matrix.rustcomponents.sdk.FormattedBody
import org.matrix.rustcomponents.sdk.GalleryUploadParameters
import org.matrix.rustcomponents.sdk.MessageFormat import org.matrix.rustcomponents.sdk.MessageFormat
import org.matrix.rustcomponents.sdk.PollData import org.matrix.rustcomponents.sdk.PollData
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle 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( override suspend fun createPoll(
question: String, question: String,
answers: List<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.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType 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.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.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType 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.MsgLikeKind
import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.use
import org.matrix.rustcomponents.sdk.FormattedBody as RustFormattedBody 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.MessageFormat as RustMessageFormat
import org.matrix.rustcomponents.sdk.MessageType as RustMessageType 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 { class EventMessageMapper {
private val inReplyToMapper by lazy { InReplyToMapper(TimelineEventContentMapper()) } private val inReplyToMapper by lazy { InReplyToMapper(TimelineEventContentMapper()) }
@@ -124,8 +124,64 @@ class EventMessageMapper {
OtherMessageType(type.msgtype, type.body) OtherMessageType(type.msgtype, type.body)
} }
is MessageType.Gallery -> { is MessageType.Gallery -> {
// TODO expose the GalleryType. GalleryMessageType(
OtherMessageType(MSG_TYPE_GALLERY_UNSTABLE, type.content.body) 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,
)
} }
} }
} }
@@ -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.core.TransactionId
import io.element.android.libraries.matrix.api.media.AudioInfo 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.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.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaUploadHandler import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.media.VideoInfo 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: ( var sendLocationLambda: (
body: String, body: String,
geoUri: String, geoUri: String,
@@ -23,12 +23,23 @@ interface PickerProvider {
onResult: (Uri?) -> Unit onResult: (Uri?) -> Unit
): PickerLauncher<PickVisualMediaRequest, Uri?> ): PickerLauncher<PickVisualMediaRequest, Uri?>
@Composable
fun registerGalleryMultiPicker(
onResult: (uris: List<Uri>) -> Unit
): PickerLauncher<PickVisualMediaRequest, List<Uri>>
@Composable @Composable
fun registerFilePicker( fun registerFilePicker(
mimeType: String, mimeType: String,
onResult: (uri: Uri?, mimeType: String?) -> Unit, onResult: (uri: Uri?, mimeType: String?) -> Unit,
): PickerLauncher<String, Uri?> ): PickerLauncher<String, Uri?>
@Composable
fun registerFileMultiPicker(
mimeType: String,
onResult: (uris: List<Uri>) -> Unit,
): PickerLauncher<Array<String>, List<Uri>>
@Composable @Composable
fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean> fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean>
@@ -15,6 +15,9 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.libraries.core.mimetype.MimeTypes 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 @Immutable
sealed interface PickerType<Input, Output> { sealed interface PickerType<Input, Output> {
fun getContract(): ActivityResultContract<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 { object Camera {
data class Photo(val destUri: Uri) : PickerType<Uri, Boolean> { data class Photo(val destUri: Uri) : PickerType<Uri, Boolean> {
override fun getContract() = ActivityResultContracts.TakePicture() override fun getContract() = ActivityResultContracts.TakePicture()
@@ -56,4 +66,13 @@ sealed interface PickerType<Input, Output> {
return mimeType 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). * 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. * [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. * 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. * @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 tmpFile = remember { getTemporaryFile("photo.jpg") }
val tmpFileUri = remember(tmpFile) { getTemporaryUri(tmpFile) } val tmpFileUri = remember(tmpFile) { getTemporaryUri(tmpFile) }
rememberPickerLauncher(type = PickerType.Camera.Photo(tmpFileUri)) { success -> rememberPickerLauncher(type = PickerType.Camera.Photo(tmpFileUri)) { success ->
// Execute callback
onResult(if (success) tmpFileUri else null) onResult(if (success) tmpFileUri else null)
} }
} }
@@ -125,7 +159,6 @@ class DefaultPickerProvider(
*/ */
@Composable @Composable
override fun registerCameraVideoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean> { 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) { return if (LocalInspectionMode.current) {
NoOpPickerLauncher { onResult(null) } NoOpPickerLauncher { onResult(null) }
} else { } else {
@@ -30,11 +30,21 @@ class FakePickerProvider : PickerProvider {
return NoOpPickerLauncher { onResult(result) } 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 @Composable
override fun registerFilePicker(mimeType: String, onResult: (Uri?, String?) -> Unit): PickerLauncher<String, Uri?> { override fun registerFilePicker(mimeType: String, onResult: (Uri?, String?) -> Unit): PickerLauncher<String, Uri?> {
return NoOpPickerLauncher { onResult(result, this.mimeType) } 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 @Composable
override fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean> { override fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean> {
return NoOpPickerLauncher { onResult(result) } return NoOpPickerLauncher { onResult(result) }
@@ -61,5 +61,12 @@ interface MediaSender {
inReplyToEventId: EventId? = null, inReplyToEventId: EventId? = null,
): Result<Unit> ): Result<Unit>
suspend fun sendGallery(
mediaUploadInfos: List<MediaUploadInfo>,
caption: String?,
formattedCaption: String?,
inReplyToEventId: EventId?,
): Result<Unit>
fun cleanUp() 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.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo 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.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.media.VideoInfo
import java.io.File import java.io.File
@@ -31,3 +32,30 @@ fun MediaUploadInfo.allFiles(): List<File> {
(this@allFiles as? MediaUploadInfo.Video)?.thumbnailFile, (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.MediaSenderFactory
import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory
import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.api.toGalleryItemInfo
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import timber.log.Timber import timber.log.Timber
@@ -167,6 +168,29 @@ class DefaultMediaSender(
.handleSendResult(mediaId(uri)) .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 private fun Result<Unit>.handleSendResult(mediaId: String) = this
.onFailure { error -> .onFailure { error ->
val job = ongoingUploadJobs.remove(Job) val job = ongoingUploadJobs.remove(Job)
@@ -19,6 +19,7 @@ class FakeMediaSender(
private val sendPreProcessedMediaResult: () -> Result<Unit> = { lambdaError() }, private val sendPreProcessedMediaResult: () -> Result<Unit> = { lambdaError() },
private val sendMediaResult: () -> Result<Unit> = { lambdaError() }, private val sendMediaResult: () -> Result<Unit> = { lambdaError() },
private val sendVoiceMessageResult: () -> Result<Unit> = { lambdaError() }, private val sendVoiceMessageResult: () -> Result<Unit> = { lambdaError() },
private val sendGalleryResult: () -> Result<Unit> = { lambdaError() },
private val cleanUpResult: () -> Unit = { lambdaError() }, private val cleanUpResult: () -> Unit = { lambdaError() },
) : MediaSender { ) : MediaSender {
override suspend fun preProcessMedia( override suspend fun preProcessMedia(
@@ -58,6 +59,15 @@ class FakeMediaSender(
return sendVoiceMessageResult() return sendVoiceMessageResult()
} }
override suspend fun sendGallery(
mediaUploadInfos: List<MediaUploadInfo>,
caption: String?,
formattedCaption: String?,
inReplyToEventId: EventId?,
): Result<Unit> {
return sendGalleryResult()
}
override fun cleanUp() { override fun cleanUp() {
cleanUpResult() 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,24 @@ import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.parcelize.Parcelize 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,
val isAttachment: Boolean,
) : Parcelable
@Parcelize
data class AvatarInfo(
val filename: String,
) : Parcelable
@Parcelize @Parcelize
data class MediaInfo( data class MediaInfo(
val filename: String, val filename: String,
@@ -35,19 +35,29 @@ interface MediaViewerEntryPoint : FeatureEntryPoint {
fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean)
} }
data class Params( sealed interface Params : NodeInputs {
val mode: MediaViewerMode, data class RoomMedia(
val eventId: EventId?, val mode: MediaViewerMode,
val mediaInfo: MediaInfo, val eventId: EventId?,
val mediaSource: MediaSource, val mediaInfo: MediaInfo,
val thumbnailSource: MediaSource?, val mediaSource: MediaSource,
val canShowInfo: Boolean, val thumbnailSource: MediaSource?,
) : NodeInputs ) : Params
data class EventGallery(
val eventId: EventId?,
val galleryInfo: GalleryInfo,
val galleryItems: List<GalleryItemData>,
) : Params
data class Avatar(
val avatarInfo: AvatarInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
) : Params
}
sealed interface MediaViewerMode : Parcelable { sealed interface MediaViewerMode : Parcelable {
@Parcelize
data object SingleMedia : MediaViewerMode
@Parcelize @Parcelize
data class TimelineImagesAndVideos(val timelineMode: Timeline.Mode) : MediaViewerMode 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.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.architecture.createNode 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.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.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.impl.viewer.MediaViewerNode import io.element.android.libraries.mediaviewer.impl.viewer.MediaViewerNode
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultMediaViewerEntryPoint : MediaViewerEntryPoint { class DefaultMediaViewerEntryPoint : MediaViewerEntryPoint {
override fun createParamsForAvatar(filename: String, avatarUrl: String): MediaViewerEntryPoint.Params { override fun createParamsForAvatar(filename: String, avatarUrl: String): MediaViewerEntryPoint.Params {
// We need to fake the MimeType here for the viewer to work. return MediaViewerEntryPoint.Params.Avatar(
val mimeType = MimeTypes.Images avatarInfo = AvatarInfo(
return MediaViewerEntryPoint.Params(
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
eventId = null,
mediaInfo = MediaInfo(
filename = filename, 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), mediaSource = MediaSource(url = avatarUrl),
thumbnailSource = null, 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.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.dateformatter.api.toHumanReadableDuration 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.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent 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.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent 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.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.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent
@@ -54,7 +57,7 @@ class EventItemFactory(
) { ) {
fun create( fun create(
currentTimelineItem: MatrixTimelineItem.Event, currentTimelineItem: MatrixTimelineItem.Event,
): MediaItem.Event? { ): List<MediaItem.Event> {
val event = currentTimelineItem.event val event = currentTimelineItem.event
val dateSent = dateFormatter.format( val dateSent = dateFormatter.format(
currentTimelineItem.event.timestamp, currentTimelineItem.event.timestamp,
@@ -79,7 +82,7 @@ class EventItemFactory(
is LiveLocationContent, is LiveLocationContent,
UnknownContent -> { UnknownContent -> {
Timber.w("Should not happen: ${content.javaClass.simpleName}") Timber.w("Should not happen: ${content.javaClass.simpleName}")
null emptyList()
} }
is MessageContent -> { is MessageContent -> {
when (val type = content.type) { when (val type = content.type) {
@@ -89,9 +92,93 @@ class EventItemFactory(
is LocationMessageType, is LocationMessageType,
is TextMessageType -> { is TextMessageType -> {
Timber.w("Should not happen: ${content.type}") 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, id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId, eventId = currentTimelineItem.eventId,
mediaInfo = MediaInfo( mediaInfo = MediaInfo(
@@ -111,8 +198,8 @@ class EventItemFactory(
duration = null, duration = null,
), ),
mediaSource = type.source, mediaSource = type.source,
) ))
is FileMessageType -> MediaItem.File( is FileMessageType -> listOf(MediaItem.File(
id = currentTimelineItem.uniqueId, id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId, eventId = currentTimelineItem.eventId,
mediaInfo = MediaInfo( mediaInfo = MediaInfo(
@@ -133,8 +220,8 @@ class EventItemFactory(
), ),
mediaSource = type.source, mediaSource = type.source,
// TODO We may want to add a thumbnailSource and set it to type.info?.thumbnailSource // 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, id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId, eventId = currentTimelineItem.eventId,
mediaInfo = MediaInfo( mediaInfo = MediaInfo(
@@ -155,8 +242,8 @@ class EventItemFactory(
), ),
mediaSource = type.source, mediaSource = type.source,
thumbnailSource = type.info?.thumbnailSource, thumbnailSource = type.info?.thumbnailSource,
) ))
is StickerMessageType -> MediaItem.Image( is StickerMessageType -> listOf(MediaItem.Image(
id = currentTimelineItem.uniqueId, id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId, eventId = currentTimelineItem.eventId,
mediaInfo = MediaInfo( mediaInfo = MediaInfo(
@@ -177,8 +264,8 @@ class EventItemFactory(
), ),
mediaSource = type.source, mediaSource = type.source,
thumbnailSource = type.info?.thumbnailSource, thumbnailSource = type.info?.thumbnailSource,
) ))
is VideoMessageType -> MediaItem.Video( is VideoMessageType -> listOf(MediaItem.Video(
id = currentTimelineItem.uniqueId, id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId, eventId = currentTimelineItem.eventId,
mediaInfo = MediaInfo( mediaInfo = MediaInfo(
@@ -199,8 +286,8 @@ class EventItemFactory(
), ),
mediaSource = type.source, mediaSource = type.source,
thumbnailSource = type.info?.thumbnailSource, thumbnailSource = type.info?.thumbnailSource,
) ))
is VoiceMessageType -> MediaItem.Voice( is VoiceMessageType -> listOf(MediaItem.Voice(
id = currentTimelineItem.uniqueId, id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId, eventId = currentTimelineItem.eventId,
mediaInfo = MediaInfo( mediaInfo = MediaInfo(
@@ -220,9 +307,36 @@ class EventItemFactory(
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(), duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
), ),
mediaSource = type.source, 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 package io.element.android.libraries.mediaviewer.impl.datasource
import dev.zacsweers.metro.Inject 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.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.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.mediaviewer.impl.model.MediaItem import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -32,18 +30,8 @@ class TimelineMediaItemsFactory(
) { ) {
private val _timelineItems = MutableSharedFlow<ImmutableList<MediaItem>>(replay = 1) private val _timelineItems = MutableSharedFlow<ImmutableList<MediaItem>>(replay = 1)
private val lock = Mutex() private val lock = Mutex()
private val diffCache = MutableListDiffCache<MediaItem>() private val cache = mutableMapOf<UniqueId, List<MediaItem>>()
private val diffCacheUpdater = DiffCacheUpdater<MatrixTimelineItem, MediaItem>( private var previousTimelineItems: List<MatrixTimelineItem> = emptyList()
diffCache = diffCache,
detectMoves = false,
cacheInvalidator = DefaultDiffCacheInvalidator()
) { old, new ->
if (old is MatrixTimelineItem.Event && new is MatrixTimelineItem.Event) {
old.uniqueId == new.uniqueId
} else {
false
}
}
val timelineItems: Flow<ImmutableList<MediaItem>> = _timelineItems.distinctUntilChanged() val timelineItems: Flow<ImmutableList<MediaItem>> = _timelineItems.distinctUntilChanged()
@@ -51,39 +39,42 @@ class TimelineMediaItemsFactory(
timelineItems: List<MatrixTimelineItem>, timelineItems: List<MatrixTimelineItem>,
) = withContext(dispatchers.computation) { ) = withContext(dispatchers.computation) {
lock.withLock { lock.withLock {
diffCacheUpdater.updateWith(timelineItems) val newTimelineItemStates = ArrayList<MediaItem>()
buildAndEmitTimelineItemStates(timelineItems) for (index in timelineItems.indices.reversed()) {
} when (val currentTimelineItem = timelineItems[index]) {
} is MatrixTimelineItem.Event -> {
val cachedItems = cache[currentTimelineItem.uniqueId]
private suspend fun buildAndEmitTimelineItemStates( val items = if (cachedItems != null && isItemUnchanged(currentTimelineItem, previousTimelineItems)) {
timelineItems: List<MatrixTimelineItem>, cachedItems
) { } else {
val newTimelineItemStates = ArrayList<MediaItem>() eventItemFactory.create(currentTimelineItem).also { newItems ->
for (index in diffCache.indices().reversed()) { cache[currentTimelineItem.uniqueId] = newItems
val cacheItem = diffCache.get(index) }
if (cacheItem == null) { }
buildAndCacheItem(timelineItems, index)?.also { timelineItemState -> newTimelineItemStates.addAll(items.asReversed())
newTimelineItemStates.add(timelineItemState) }
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( private fun isItemUnchanged(
timelineItems: List<MatrixTimelineItem>, currentTimelineItem: MatrixTimelineItem.Event,
index: Int, previousItems: List<MatrixTimelineItem>,
): MediaItem? { ): Boolean {
val timelineItem = if (previousItems.isEmpty()) return false
when (val currentTimelineItem = timelineItems[index]) { val previousIndex = previousItems.indexOfFirst { it is MatrixTimelineItem.Event && it.uniqueId == currentTimelineItem.uniqueId }
is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem) if (previousIndex < 0) return false
is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem) val previousItem = previousItems[previousIndex] as? MatrixTimelineItem.Event ?: return false
MatrixTimelineItem.Other -> null return previousItem.event.eventId == currentTimelineItem.event.eventId &&
} previousItem.event.timestamp == currentTimelineItem.event.timestamp
diffCache[index] = timelineItem
return timelineItem
} }
} }
@@ -128,13 +128,12 @@ class MediaGalleryFlowNode(
mediaViewerEntryPoint.createNode( mediaViewerEntryPoint.createNode(
parentNode = this, parentNode = this,
buildContext = buildContext, buildContext = buildContext,
params = MediaViewerEntryPoint.Params( params = MediaViewerEntryPoint.Params.RoomMedia(
mode = navTarget.mode, mode = navTarget.mode,
eventId = navTarget.eventId, eventId = navTarget.eventId,
mediaInfo = navTarget.mediaInfo, mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource, mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource, thumbnailSource = navTarget.thumbnailSource,
canShowInfo = true,
), ),
callback = callback, callback = callback,
) )
@@ -0,0 +1,102 @@
/*
* 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.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 imageAndVideoItems = mutableListOf<MediaItem.Event>()
val fileItems = 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,
)
}
when (mediaItem) {
is MediaItem.Image, is MediaItem.Video -> imageAndVideoItems.add(mediaItem)
is MediaItem.Audio, is MediaItem.File, is MediaItem.Voice -> fileItems.add(mediaItem)
}
}
return GalleryMediaGalleryDataSource(
data = GroupedMediaItems(
imageAndVideoItems = imageAndVideoItems.toImmutableList(),
fileItems = fileItems.toImmutableList(),
)
)
}
}
}
@@ -62,7 +62,6 @@ class MediaViewerDataSource(
private val mediaFiles: ConcurrentHashMap<MediaSource, MediaFile> = ConcurrentHashMap() private val mediaFiles: ConcurrentHashMap<MediaSource, MediaFile> = ConcurrentHashMap()
private val galleryMode = when (mode) { private val galleryMode = when (mode) {
MediaViewerMode.SingleMedia,
is MediaViewerMode.TimelineImagesAndVideos -> MediaGalleryMode.Images is MediaViewerMode.TimelineImagesAndVideos -> MediaGalleryMode.Images
is MediaViewerMode.TimelineFilesAndAudios -> MediaGalleryMode.Files is MediaViewerMode.TimelineFilesAndAudios -> MediaGalleryMode.Files
} }
@@ -98,9 +97,12 @@ class MediaViewerDataSource(
/** /**
* Find the index of the page corresponding to the given eventId, or null if not found. * 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 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 @VisibleForTesting
@@ -74,42 +74,49 @@ class MediaViewerNode(
callback.onDone() callback.onDone()
} }
private val mediaGallerySource = if (inputs.mode == MediaViewerEntryPoint.MediaViewerMode.SingleMedia) { private val mediaGallerySource = when (inputs) {
SingleMediaGalleryDataSource.createFrom(inputs) is MediaViewerEntryPoint.Params.Avatar ->
} else { SingleMediaGalleryDataSource.createFrom(inputs)
val eventId = inputs.eventId is MediaViewerEntryPoint.Params.EventGallery ->
if (eventId == null) { GalleryMediaGalleryDataSource.createFrom(
// Should not happen eventId = inputs.eventId,
timelineMediaGalleryDataSource galleryItems = inputs.galleryItems,
} else { galleryInfo = inputs.galleryInfo,
// Can we use a specific timeline? )
val timelineMode = inputs.mode.getTimelineMode() is MediaViewerEntryPoint.Params.RoomMedia -> {
when (timelineMode) { val eventId = inputs.eventId
null -> timelineMediaGalleryDataSource if (eventId == null) {
Timeline.Mode.Live, // Should not happen
is Timeline.Mode.FocusedOnEvent, timelineMediaGalleryDataSource
is Timeline.Mode.Thread -> { } else {
// Does timelineMediaGalleryDataSource knows the eventId? // Can we use a specific timeline?
val lastData = timelineMediaGalleryDataSource.getLastData().dataOrNull() val timelineMode = inputs.mode.getTimelineMode()
val isEventKnown = lastData?.hasEvent(eventId) == true when (timelineMode) {
if (isEventKnown) { Timeline.Mode.Live,
timelineMediaGalleryDataSource is Timeline.Mode.FocusedOnEvent,
} else { 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( focusedTimelineMediaGalleryDataSourceFactory.createFor(
eventId = eventId, eventId = eventId,
mediaItem = inputs.toMediaItem(), mediaItem = inputs.toMediaItem(),
onlyPinnedEvents = false, onlyPinnedEvents = true,
) )
} }
Timeline.Mode.Media -> timelineMediaGalleryDataSource
} }
Timeline.Mode.PinnedEvents -> {
focusedTimelineMediaGalleryDataSourceFactory.createFor(
eventId = eventId,
mediaItem = inputs.toMediaItem(),
onlyPinnedEvents = true,
)
}
Timeline.Mode.Media -> timelineMediaGalleryDataSource
} }
} }
} }
@@ -118,7 +125,18 @@ class MediaViewerNode(
inputs = inputs, inputs = inputs,
navigator = this, navigator = this,
dataSource = MediaViewerDataSource( dataSource = MediaViewerDataSource(
mode = inputs.mode, mode = when (inputs) {
is MediaViewerEntryPoint.Params.Avatar ->
MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media)
is MediaViewerEntryPoint.Params.EventGallery ->
if (inputs.galleryInfo.isAttachment) {
MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(Timeline.Mode.Media)
} else {
MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media)
}
is MediaViewerEntryPoint.Params.RoomMedia ->
inputs.mode
},
coroutineScope = lifecycleScope, coroutineScope = lifecycleScope,
dispatcher = coroutineDispatchers.computation, dispatcher = coroutineDispatchers.computation,
galleryDataSource = mediaGallerySource, galleryDataSource = mediaGallerySource,
@@ -149,10 +167,9 @@ class MediaViewerNode(
} }
} }
internal fun MediaViewerEntryPoint.MediaViewerMode.getTimelineMode(): Timeline.Mode? { internal fun MediaViewerEntryPoint.MediaViewerMode.getTimelineMode(): Timeline.Mode {
return when (this) { return when (this) {
is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> timelineMode is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> timelineMode
is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> timelineMode is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> timelineMode
else -> null
} }
} }
@@ -67,20 +67,31 @@ class MediaViewerPresenter(
// Use a local snackbarDispatcher because this presenter is used in an Overlay Node // Use a local snackbarDispatcher because this presenter is used in an Overlay Node
private val snackbarDispatcher = SnackbarDispatcher() private val snackbarDispatcher = SnackbarDispatcher()
private val eventId = inputs.eventId()
private val mediaSource = inputs.mediaSource()
@Composable @Composable
override fun present(): MediaViewerState { override fun present(): MediaViewerState {
val coroutineScope = rememberCoroutineScope() 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 -> val data = dataSource.produceState { flow ->
flow.collectLatest { new -> flow.collectLatest { new ->
val existingItem = value.getOrNull(currentIndex.intValue) val existingItem = value.getOrNull(currentIndex.intValue)
val newItem = new.getOrNull(currentIndex.intValue) val newItem = new.getOrNull(currentIndex.intValue)
if (existingItem is MediaViewerPageData.MediaViewerData && existingItem.eventId == inputs.eventId && newItem != existingItem) { if (existingItem is MediaViewerPageData.MediaViewerData && existingItem.eventId == eventId && newItem != existingItem) {
currentIndex.intValue = dataSource.findEventIndex(inputs.eventId) ?: 0 currentIndex.intValue = dataSource.findEventIndex(eventId, mediaSource) ?: 0
} else if (currentIndex.intValue > 0 && value.firstOrNull() is MediaViewerPageData.Loading && } else if (currentIndex.intValue > 0 && value.firstOrNull() is MediaViewerPageData.Loading &&
new.firstOrNull() !is MediaViewerPageData.Loading) { new.firstOrNull() !is MediaViewerPageData.Loading) {
// Restore index based on the eventId after the initial items have been loaded // 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 value = new
} }
@@ -140,7 +151,8 @@ class MediaViewerPresenter(
mediaBottomSheetState = MediaBottomSheetState.Hidden mediaBottomSheetState = MediaBottomSheetState.Hidden
navigator.onForwardClick( navigator.onForwardClick(
eventId = event.eventId, eventId = event.eventId,
fromPinnedEvents = inputs.mode.getTimelineMode() == Timeline.Mode.PinnedEvents, // TODO We can have a pinned gallery
fromPinnedEvents = inputs.mode()?.getTimelineMode() == Timeline.Mode.PinnedEvents,
) )
} }
is MediaViewerEvent.OpenInfo -> coroutineScope.launch { is MediaViewerEvent.OpenInfo -> coroutineScope.launch {
@@ -176,11 +188,11 @@ class MediaViewerPresenter(
} }
return MediaViewerState( return MediaViewerState(
initiallySelectedEventId = inputs.eventId, initiallySelectedEventId = eventId,
listData = data.value, listData = data.value,
currentIndex = currentIndex.intValue, currentIndex = currentIndex.intValue,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
canShowInfo = inputs.canShowInfo, canShowInfo = inputs !is MediaViewerEntryPoint.Params.Avatar,
mediaBottomSheetState = mediaBottomSheetState, mediaBottomSheetState = mediaBottomSheetState,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
@@ -224,13 +236,14 @@ class MediaViewerPresenter(
} }
private fun showNoMoreItemsSnackbar() { private fun showNoMoreItemsSnackbar() {
val messageResId = when (inputs.mode) { if (inputs is MediaViewerEntryPoint.Params.RoomMedia) {
MediaViewerEntryPoint.MediaViewerMode.SingleMedia, val messageResId = when (inputs.mode) {
is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> R.string.screen_media_details_no_more_media_to_show 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 is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> R.string.screen_media_details_no_more_files_to_show
}
val message = SnackbarMessage(messageResId)
snackbarDispatcher.post(message)
} }
val message = SnackbarMessage(messageResId)
snackbarDispatcher.post(message)
} }
private fun CoroutineScope.downloadMedia( private fun CoroutineScope.downloadMedia(
@@ -292,3 +305,21 @@ 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
}
private fun MediaViewerEntryPoint.Params.mode() = when (this) {
is MediaViewerEntryPoint.Params.Avatar -> null
is MediaViewerEntryPoint.Params.EventGallery -> null
is MediaViewerEntryPoint.Params.RoomMedia -> mode
}
@@ -11,6 +11,7 @@ package io.element.android.libraries.mediaviewer.impl.viewer
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.libraries.mediaviewer.impl.model.MediaItem 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.eventId
import io.element.android.libraries.mediaviewer.impl.model.mediaSource
/** /**
* x and y are loading items. * x and y are loading items.
@@ -50,31 +51,32 @@ class PagerKeysHandler {
if (cachedData.mediaItems.isEmpty()) { if (cachedData.mediaItems.isEmpty()) {
cachedData = Data(mediaItems, 0) cachedData = Data(mediaItems, 0)
} else { } else {
// Search a common item in both lists, i.e. an item with the same eventId // Search a common item in both lists using eventId + mediaSource to handle gallery items
val itemInCacheIndex = cachedData.mediaItems.indexOfFirst { mediaItem -> val itemInCacheIndex = cachedData.mediaItems.indexOfFirst { cachedItem ->
mediaItem is MediaItem.Event && mediaItems cachedItem is MediaItem.Event && mediaItems
.filterIsInstance<MediaItem.Event>() .filterIsInstance<MediaItem.Event>()
.any { mediaItem.eventId() == it.eventId() } .any { newItem ->
cachedItem.eventId() == newItem.eventId() &&
cachedItem.mediaSource().safeUrl == newItem.mediaSource().safeUrl
}
} }
cachedData = if (itemInCacheIndex == -1) { cachedData = if (itemInCacheIndex == -1) {
// If the item is not found, start with a new cache
Data(mediaItems, 0) Data(mediaItems, 0)
} else { } else {
val cachedItem = cachedData.mediaItems[itemInCacheIndex] val cachedItem = cachedData.mediaItems[itemInCacheIndex]
val eventId = (cachedItem as? MediaItem.Event)?.eventId() val eventId = (cachedItem as? MediaItem.Event)?.eventId()
if (eventId == null) { val cachedSourceUrl = (cachedItem as? MediaItem.Event)?.mediaSource()?.safeUrl
// Should not happen, but in this case, start with a new cache if (eventId == null || cachedSourceUrl == null) {
Data(mediaItems, 0) Data(mediaItems, 0)
} else { } else {
// Search the index of the item in the new list
val itemIndex = mediaItems.indexOfFirst { mediaItem -> 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 (itemIndex == -1) {
// If the item is not found, start with a new cache
Data(mediaItems, 0) Data(mediaItems, 0)
} else { } else {
// Update the cache with the new list and the new offset
Data(mediaItems, cachedData.keyOffset + itemInCacheIndex - itemIndex.toLong()) Data(mediaItems, cachedData.keyOffset + itemInCacheIndex - itemIndex.toLong())
} }
} }
@@ -9,12 +9,14 @@
package io.element.android.libraries.mediaviewer.impl.viewer package io.element.android.libraries.mediaviewer.impl.viewer
import io.element.android.libraries.architecture.AsyncData 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.isMimeTypeAudio
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo 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.EventId
import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.Timeline 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.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource 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.GroupedMediaItems
@@ -36,17 +38,40 @@ class SingleMediaGalleryDataSource(
override suspend fun deleteItem(eventId: EventId) = Unit override suspend fun deleteItem(eventId: EventId) = Unit
companion object { companion object {
fun createFrom(params: MediaViewerEntryPoint.Params) = SingleMediaGalleryDataSource( fun createFrom(params: MediaViewerEntryPoint.Params.Avatar) = SingleMediaGalleryDataSource(
data = GroupedMediaItems( data = GroupedMediaItems(
// Always use imageAndVideoItems, in Single mode, this is the data that will be used // 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(), fileItems = persistentListOf(),
) )
) )
} }
} }
fun MediaViewerEntryPoint.Params.toMediaItem() = when { fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
mediaInfo.mimeType.isMimeTypeImage() -> { mediaInfo.mimeType.isMimeTypeImage() -> {
MediaItem.Image( MediaItem.Image(
id = UniqueId("dummy"), id = UniqueId("dummy"),
@@ -13,14 +13,12 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.FakeEnterpriseService 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.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.api.media.MediaSource
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
import io.element.android.libraries.mediaplayer.test.FakeAudioFocus 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.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.impl.datasource.createTimelineMediaGalleryDataSource import io.element.android.libraries.mediaviewer.impl.datasource.createTimelineMediaGalleryDataSource
import io.element.android.libraries.mediaviewer.impl.viewer.MediaViewerNode import io.element.android.libraries.mediaviewer.impl.viewer.MediaViewerNode
@@ -128,7 +126,7 @@ class DefaultMediaViewerEntryPointTest {
override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError() override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError()
} }
val params = entryPoint.createParamsForAvatar( val params = entryPoint.createParamsForAvatar(
filename = "fn", filename = "avatar.png",
avatarUrl = "avatarUrl", avatarUrl = "avatarUrl",
) )
val result = entryPoint.createNode( val result = entryPoint.createNode(
@@ -139,27 +137,10 @@ class DefaultMediaViewerEntryPointTest {
) )
assertThat(result).isInstanceOf(MediaViewerNode::class.java) assertThat(result).isInstanceOf(MediaViewerNode::class.java)
assertThat(result.plugins).contains( assertThat(result.plugins).contains(
MediaViewerEntryPoint.Params( MediaViewerEntryPoint.Params.Avatar(
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia, avatarInfo = AvatarInfo(filename = "avatar.png"),
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,
),
mediaSource = MediaSource(url = "avatarUrl"), mediaSource = MediaSource(url = "avatarUrl"),
thumbnailSource = null, thumbnailSource = null,
canShowInfo = false,
) )
) )
assertThat(result.plugins).contains(callback) assertThat(result.plugins).contains(callback)
@@ -97,7 +97,7 @@ class DefaultEventItemFactoryTest {
) )
) )
) )
assertThat(result).isNull() assertThat(result).isEmpty()
} }
} }
@@ -122,7 +122,7 @@ class DefaultEventItemFactoryTest {
) )
) )
) )
assertThat(result).isNull() assertThat(result).isEmpty()
} }
} }
@@ -150,7 +150,7 @@ class DefaultEventItemFactoryTest {
) )
) )
) )
assertThat(result).isEqualTo( assertThat(result).containsExactly(
MediaItem.File( MediaItem.File(
id = A_UNIQUE_ID, id = A_UNIQUE_ID,
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
@@ -201,7 +201,7 @@ class DefaultEventItemFactoryTest {
) )
) )
) )
assertThat(result).isEqualTo( assertThat(result).containsExactly(
MediaItem.Image( MediaItem.Image(
id = A_UNIQUE_ID, id = A_UNIQUE_ID,
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
@@ -249,7 +249,7 @@ class DefaultEventItemFactoryTest {
) )
) )
) )
assertThat(result).isEqualTo( assertThat(result).containsExactly(
MediaItem.Audio( MediaItem.Audio(
id = A_UNIQUE_ID, id = A_UNIQUE_ID,
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
@@ -301,7 +301,7 @@ class DefaultEventItemFactoryTest {
) )
) )
) )
assertThat(result).isEqualTo( assertThat(result).containsExactly(
MediaItem.Video( MediaItem.Video(
id = A_UNIQUE_ID, id = A_UNIQUE_ID,
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
@@ -353,7 +353,7 @@ class DefaultEventItemFactoryTest {
) )
) )
) )
assertThat(result).isEqualTo( assertThat(result).containsExactly(
MediaItem.Voice( MediaItem.Voice(
id = A_UNIQUE_ID, id = A_UNIQUE_ID,
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
@@ -404,7 +404,7 @@ class DefaultEventItemFactoryTest {
) )
) )
) )
assertThat(result).isEqualTo( assertThat(result).containsExactly(
MediaItem.Image( MediaItem.Image(
id = A_UNIQUE_ID, id = A_UNIQUE_ID,
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
@@ -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.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.libraries.matrix.test.timeline.FakeTimeline 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.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
@@ -109,7 +110,11 @@ class MediaViewerPresenterTest {
fun `present - initial state cannot show info`() = runTest { fun `present - initial state cannot show info`() = runTest {
val presenter = createMediaViewerPresenter( val presenter = createMediaViewerPresenter(
localMediaFactory = localMediaFactory, localMediaFactory = localMediaFactory,
canShowInfo = false, inputs = MediaViewerEntryPoint.Params.Avatar(
avatarInfo = AvatarInfo(filename = "avatar.png"),
mediaSource = aMediaSource(),
thumbnailSource = null,
),
room = FakeJoinedRoom( room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
roomPermissions = FakeRoomPermissions( roomPermissions = FakeRoomPermissions(
@@ -639,7 +644,7 @@ class MediaViewerPresenterTest {
} }
) )
) )
skipItems(2) skipItems(1)
val stateWithSnackbar = awaitItem() val stateWithSnackbar = awaitItem()
assertThat(stateWithSnackbar.snackbarMessage!!.messageResId).isEqualTo(expectedSnackbarResId) assertThat(stateWithSnackbar.snackbarMessage!!.messageResId).isEqualTo(expectedSnackbarResId)
} }
@@ -932,29 +937,31 @@ class MediaViewerPresenterTest {
internal fun TestScope.createMediaViewerPresenter( internal fun TestScope.createMediaViewerPresenter(
localMediaFactory: LocalMediaFactory, localMediaFactory: LocalMediaFactory,
inputs: MediaViewerEntryPoint.Params? = null,
eventId: EventId? = null, eventId: EventId? = null,
mode: MediaViewerEntryPoint.MediaViewerMode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia, mode: MediaViewerEntryPoint.MediaViewerMode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media),
matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(), matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(),
localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(), localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(),
mediaGalleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource( mediaGalleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { }, startLambda = { },
), ),
canShowInfo: Boolean = true,
mediaViewerNavigator: MediaViewerNavigator = FakeMediaViewerNavigator(), mediaViewerNavigator: MediaViewerNavigator = FakeMediaViewerNavigator(),
room: JoinedRoom = FakeJoinedRoom( room: JoinedRoom = FakeJoinedRoom(
liveTimeline = FakeTimeline(), liveTimeline = FakeTimeline(),
), ),
): MediaViewerPresenter { ): 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( return MediaViewerPresenter(
inputs = createMediaViewerEntryPointParams( inputs = actualInputs,
eventId = eventId,
mode = mode,
canShowInfo = canShowInfo,
),
navigator = mediaViewerNavigator, navigator = mediaViewerNavigator,
dataSource = MediaViewerDataSource( dataSource = MediaViewerDataSource(
coroutineScope = backgroundScope, coroutineScope = backgroundScope,
mode = mode, mode = actualMode,
dispatcher = testCoroutineDispatchers().computation, dispatcher = testCoroutineDispatchers().computation,
galleryDataSource = mediaGalleryDataSource, galleryDataSource = mediaGalleryDataSource,
mediaLoader = matrixMediaLoader, mediaLoader = matrixMediaLoader,
@@ -969,13 +976,11 @@ internal fun TestScope.createMediaViewerPresenter(
internal fun createMediaViewerEntryPointParams( internal fun createMediaViewerEntryPointParams(
eventId: EventId? = null, eventId: EventId? = null,
mode: MediaViewerEntryPoint.MediaViewerMode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia, mode: MediaViewerEntryPoint.MediaViewerMode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode = Timeline.Mode.Media),
canShowInfo: Boolean = true, ) = MediaViewerEntryPoint.Params.RoomMedia(
) = MediaViewerEntryPoint.Params(
mode = mode, mode = mode,
eventId = eventId, eventId = eventId,
mediaInfo = TESTED_MEDIA_INFO, mediaInfo = TESTED_MEDIA_INFO,
mediaSource = aMediaSource(), mediaSource = aMediaSource(),
thumbnailSource = null, thumbnailSource = null,
canShowInfo = canShowInfo,
) )
@@ -160,23 +160,20 @@ class SingleMediaGalleryDataSourceTest {
private fun testFactory( private fun testFactory(
mediaInfo: MediaInfo, mediaInfo: MediaInfo,
expectedResult: (MediaViewerEntryPoint.Params) -> MediaItem, expectedResult: (MediaViewerEntryPoint.Params.RoomMedia) -> MediaItem,
) { ) {
val params = aMediaViewerEntryPointParams(mediaInfo) val params = aMediaViewerEntryPointParams(mediaInfo)
val result = SingleMediaGalleryDataSource.createFrom(params) val result = params.toMediaItem()
val resultData = result.getLastData().dataOrNull() assertThat(result).isEqualTo(expectedResult(params))
assertThat(resultData!!.imageAndVideoItems.first()).isEqualTo(expectedResult(params))
assertThat(resultData.fileItems).isEmpty()
} }
internal fun aMediaViewerEntryPointParams( internal fun aMediaViewerEntryPointParams(
mediaInfo: MediaInfo, mediaInfo: MediaInfo,
) = MediaViewerEntryPoint.Params( ) = MediaViewerEntryPoint.Params.RoomMedia(
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia, mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media),
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
mediaInfo = mediaInfo, mediaInfo = mediaInfo,
mediaSource = aMediaSource(url = "aUrl"), mediaSource = aMediaSource(url = "aUrl"),
thumbnailSource = aMediaSource(url = "aThumbnailUrl"), 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.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EventType 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.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.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType 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.NoticeMessageType
@@ -344,6 +345,7 @@ class DefaultNotifiableEventResolver(
is TextMessageType -> messageType.toPlainText(permalinkParser = permalinkParser) is TextMessageType -> messageType.toPlainText(permalinkParser = permalinkParser)
is VideoMessageType -> messageType.bestDescription is VideoMessageType -> messageType.bestDescription
is LocationMessageType -> messageType.body is LocationMessageType -> messageType.body
is GalleryMessageType -> messageType.body
is OtherMessageType -> messageType.body is OtherMessageType -> messageType.body
} }
} }
@@ -5,6 +5,8 @@
<string name="a11y_address">"Address"</string> <string name="a11y_address">"Address"</string>
<string name="a11y_avatar">"Avatar"</string> <string name="a11y_avatar">"Avatar"</string>
<string name="a11y_collapse_message_text_field">"Minimise message text field"</string> <string name="a11y_collapse_message_text_field">"Minimise message text field"</string>
<string name="a11y_create_poll_votes_allowed_decrease">"Decrease votes allowed per person"</string>
<string name="a11y_create_poll_votes_allowed_increase">"Increase votes allowed per person"</string>
<string name="a11y_delete">"Delete"</string> <string name="a11y_delete">"Delete"</string>
<plurals name="a11y_digits_entered"> <plurals name="a11y_digits_entered">
<item quantity="one">"%1$d digit entered"</item> <item quantity="one">"%1$d digit entered"</item>
@@ -194,9 +196,11 @@
<string name="action_translate">"Translate"</string> <string name="action_translate">"Translate"</string>
<string name="action_try_again">"Try again"</string> <string name="action_try_again">"Try again"</string>
<string name="action_unpin">"Unpin"</string> <string name="action_unpin">"Unpin"</string>
<string name="action_video_call">"Video call"</string>
<string name="action_view">"View"</string> <string name="action_view">"View"</string>
<string name="action_view_in_timeline">"View in timeline"</string> <string name="action_view_in_timeline">"View in timeline"</string>
<string name="action_view_source">"View source"</string> <string name="action_view_source">"View source"</string>
<string name="action_voice_call">"Voice call"</string>
<string name="action_yes">"Yes"</string> <string name="action_yes">"Yes"</string>
<string name="action_yes_try_again">"Yes, try again"</string> <string name="action_yes_try_again">"Yes, try again"</string>
<string name="banner_migrate_to_native_sliding_sync_description">"Your server now supports a new, faster protocol. Log out and log back in to upgrade now. Doing this now will help you avoid a forced logout when the old protocol is removed later."</string> <string name="banner_migrate_to_native_sliding_sync_description">"Your server now supports a new, faster protocol. Log out and log back in to upgrade now. Doing this now will help you avoid a forced logout when the old protocol is removed later."</string>
@@ -261,6 +265,7 @@ Reason: %1$s."</string>
<string name="common_file_saved_on_disk_android">"File saved to Downloads"</string> <string name="common_file_saved_on_disk_android">"File saved to Downloads"</string>
<string name="common_forward_message">"Forward message"</string> <string name="common_forward_message">"Forward message"</string>
<string name="common_frequently_used">"Frequently used"</string> <string name="common_frequently_used">"Frequently used"</string>
<string name="common_gallery">"Gallery"</string>
<string name="common_gif">"GIF"</string> <string name="common_gif">"GIF"</string>
<string name="common_group_call_in_progress">"Group call in progress"</string> <string name="common_group_call_in_progress">"Group call in progress"</string>
<string name="common_image">"Image"</string> <string name="common_image">"Image"</string>
@@ -488,6 +493,7 @@ Are you sure you want to continue?"</string>
<string name="screen_create_poll_options_section_title">"Options"</string> <string name="screen_create_poll_options_section_title">"Options"</string>
<string name="screen_create_poll_remove_accessibility_label">"Remove %1$s"</string> <string name="screen_create_poll_remove_accessibility_label">"Remove %1$s"</string>
<string name="screen_create_poll_settings_section_title">"Settings"</string> <string name="screen_create_poll_settings_section_title">"Settings"</string>
<string name="screen_create_poll_votes_allowed_per_person">"Votes allowed per person"</string>
<string name="screen_live_location_sheet_nobody_sharing">"Nobody is sharing their location"</string> <string name="screen_live_location_sheet_nobody_sharing">"Nobody is sharing their location"</string>
<string name="screen_live_location_sheet_sharing_live_location">"Sharing live location"</string> <string name="screen_live_location_sheet_sharing_live_location">"Sharing live location"</string>
<plurals name="screen_live_location_sheet_subtitle"> <plurals name="screen_live_location_sheet_subtitle">
@@ -140,7 +140,10 @@ class KonsistClassNameTest {
Konsist.scopeFromProject() Konsist.scopeFromProject()
.classes() .classes()
.withNameEndingWith("Impl") .withNameEndingWith("Impl")
.withoutName("MediaUploadHandlerImpl") .withoutName(
"MediaUploadHandlerImpl",
"GalleryMediaUploadHandlerImpl",
)
.assertEmpty(additionalMessage = "Class implementing interface should have name not end with 'Impl' but start with 'Default'") .assertEmpty(additionalMessage = "Class implementing interface should have name not end with 'Impl' but start with 'Default'")
} }
@@ -149,6 +149,7 @@ class KonsistPreviewTest {
"TimelineItemEventRowShieldPreview", "TimelineItemEventRowShieldPreview",
"TimelineItemEventRowTimestampPreview", "TimelineItemEventRowTimestampPreview",
"TimelineItemEventRowUtdPreview", "TimelineItemEventRowUtdPreview",
"TimelineItemEventRowWithGalleryPreview",
"TimelineItemEventRowWithManyReactionsPreview", "TimelineItemEventRowWithManyReactionsPreview",
"TimelineItemEventRowWithRRPreview", "TimelineItemEventRowWithRRPreview",
"TimelineItemEventRowWithReplyPreview", "TimelineItemEventRowWithReplyPreview",

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