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.debug.EventDebugInfoNode
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.GalleryItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
@@ -86,6 +89,8 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.RoomNamesCache
import io.element.android.libraries.mediaviewer.api.GalleryInfo
import io.element.android.libraries.mediaviewer.api.GalleryItemData
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater
@@ -153,7 +158,15 @@ class MessagesFlowNode(
) : NavTarget
@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
data class LocationViewer(val mode: ShowLocationMode) : NavTarget
@@ -239,7 +252,11 @@ class MessagesFlowNode(
callback.navigateToRoomDetails()
}
override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean {
override fun handleEventClick(
timelineMode: Timeline.Mode,
event: TimelineItem.Event,
canUseOverlay: Boolean,
): Boolean {
return processEventClick(
timelineMode = timelineMode,
event = event,
@@ -247,10 +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?) {
backstack.push(
NavTarget.AttachmentPreview(
attachment = attachments.first(),
attachments = attachments,
timelineMode = Timeline.Mode.Live,
inReplyToEventId = inReplyToEventId,
)
@@ -339,13 +368,43 @@ class MessagesFlowNode(
createNode<MessagesNode>(buildContext, listOf(callback, inputs))
}
is NavTarget.MediaViewer -> {
val params = MediaViewerEntryPoint.Params(
val params = MediaViewerEntryPoint.Params.RoomMedia(
mode = navTarget.mode,
eventId = navTarget.eventId,
mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource,
canShowInfo = true,
)
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() {
if (navTarget.canUseOverlay) {
overlay.hide()
} else {
backstack.pop()
}
}
override fun viewInTimeline(eventId: EventId) {
this@MessagesFlowNode.viewInTimeline(eventId)
}
override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) {
// Need to go to the parent because of the overlay
callback.forwardEvent(eventId, fromPinnedEvents)
}
}
mediaViewerEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = params,
callback = callback
)
}
is NavTarget.GalleryViewer -> {
val params = MediaViewerEntryPoint.Params.EventGallery(
eventId = navTarget.eventId,
galleryInfo = navTarget.galleryInfo,
galleryItems = navTarget.galleryItems,
)
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() {
@@ -374,7 +433,7 @@ class MessagesFlowNode(
}
is NavTarget.AttachmentPreview -> {
val inputs = AttachmentsPreviewNode.Inputs(
attachment = navTarget.attachment,
attachments = navTarget.attachments,
timelineMode = navTarget.timelineMode,
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) {
callback.navigateToRoomMemberDetails(userId)
}
@@ -490,7 +561,11 @@ class MessagesFlowNode(
focusedEventId = navTarget.focusedEventId,
)
val callback = object : ThreadedMessagesNode.Callback {
override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean {
override fun handleEventClick(
timelineMode: Timeline.Mode,
event: TimelineItem.Event,
canUseOverlay: Boolean,
): Boolean {
return processEventClick(
timelineMode = timelineMode,
event = event,
@@ -498,10 +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?) {
backstack.push(
NavTarget.AttachmentPreview(
attachment = attachments.first(),
attachments = attachments,
timelineMode = Timeline.Mode.Thread(navTarget.threadRootId),
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(
mode: MediaViewerEntryPoint.MediaViewerMode,
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 {
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 navigateToRoomMemberDetails(userId: UserId)
fun handlePermalinkClick(data: PermalinkData)
@@ -289,6 +290,9 @@ class MessagesNode(
}
}
},
onGalleryEventItemClick = { event, index ->
callback.handleGalleryItemClick(event, index, canUseOverlay)
},
onUserDataClick = callback::navigateToRoomMemberDetails,
onLinkClick = { url, customTab ->
onLinkClick(
@@ -47,10 +47,10 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.captionOrNull
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter
import io.element.android.features.roomcall.api.RoomCallState
@@ -533,7 +533,7 @@ class MessagesPresenter(
) {
val composerMode = MessageComposerMode.EditCaption(
eventOrTransactionId = targetEvent.eventOrTransactionId,
content = (targetEvent.content as? TimelineItemEventContentWithAttachment)?.caption.orEmpty(),
content = targetEvent.content.captionOrNull().orEmpty(),
)
composerState.eventSink(
MessageComposerEvent.SetMode(composerMode)
@@ -606,7 +606,7 @@ class MessagesPresenter(
}
private fun handleCopyCaption(event: TimelineItem.Event) {
val content = (event.content as? TimelineItemEventContentWithAttachment)?.caption ?: return
val content = event.content.captionOrNull() ?: return
clipboardHelper.copyPlainText(content)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
@@ -136,6 +136,7 @@ fun MessagesView(
onBackClick: () -> Unit,
onRoomDetailsClick: () -> Unit,
onEventContentClick: (isLive: Boolean, event: TimelineItem.Event) -> Boolean,
onGalleryEventItemClick: (event: TimelineItem.Event, index: Int) -> Boolean,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String, Boolean) -> Unit,
onSendLocationClick: () -> Unit,
@@ -207,15 +208,15 @@ fun MessagesView(
val expandableState = rememberExpandableBottomSheetLayoutState()
ExpandableBottomSheetLayout(
modifier = modifier
.fillMaxSize()
.imePadding()
.systemBarsPadding()
.onSizeChanged { size ->
// Let the composer takes at max half of the available height.
// The value will be different if the soft keyboard is displayed
// or not.
maxComposerHeightPx = (size.height * 0.5f).toInt()
},
.fillMaxSize()
.imePadding()
.systemBarsPadding()
.onSizeChanged { size ->
// Let the composer takes at max half of the available height.
// The value will be different if the soft keyboard is displayed
// or not.
maxComposerHeightPx = (size.height * 0.5f).toInt()
},
content = {
Scaffold(
contentWindowInsets = WindowInsets.statusBars,
@@ -252,12 +253,18 @@ fun MessagesView(
content = { padding ->
Box(
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
.padding(padding)
.consumeWindowInsets(padding)
) {
MessagesViewContent(
state = state,
onContentClick = ::onContentClick,
onGalleryItemClick = { event, index ->
val hideKeyboard = onGalleryEventItemClick(event, index)
if (hideKeyboard) {
localView.hideKeyboard()
}
},
onMessageLongClick = ::onMessageLongClick,
onUserDataClick = {
hidingKeyboard {
@@ -290,10 +297,10 @@ fun MessagesView(
SuggestionsPickerView(
modifier = Modifier
.shadow(10.dp)
.background(ElementTheme.colors.bgCanvasDefault)
.align(Alignment.BottomStart)
.heightIn(max = 230.dp),
.shadow(10.dp)
.background(ElementTheme.colors.bgCanvasDefault)
.align(Alignment.BottomStart)
.heightIn(max = 230.dp),
roomId = state.roomId,
roomName = state.roomName,
roomAvatarData = state.roomAvatar,
@@ -459,6 +466,7 @@ private fun MessagesViewContent(
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
onMessageLongClick: (TimelineItem.Event) -> Unit,
onGalleryItemClick: ((TimelineItem.Event, Int) -> Unit),
onSendLocationClick: () -> Unit,
onCreatePollClick: () -> Unit,
onViewAllPinnedMessagesClick: () -> Unit,
@@ -469,9 +477,9 @@ private fun MessagesViewContent(
) {
Box(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding()
.imePadding(),
.fillMaxSize()
.navigationBarsPadding()
.imePadding(),
) {
AttachmentsBottomSheet(
state = state.composerState,
@@ -510,6 +518,7 @@ private fun MessagesViewContent(
onUserDataClick = onUserDataClick,
onLinkClick = { link -> onLinkClick(link, false) },
onContentClick = onContentClick,
onGalleryItemClick = onGalleryItemClick,
onMessageLongClick = onMessageLongClick,
onSwipeToReply = onSwipeToReply,
onReactionClick = onReactionClick,
@@ -598,9 +607,9 @@ private fun MessagesViewComposerBottomSheetContents(
private fun CantSendMessageBanner() {
Row(
modifier = Modifier
.fillMaxWidth()
.background(ElementTheme.colors.bgSubtleSecondary)
.padding(16.dp),
.fillMaxWidth()
.background(ElementTheme.colors.bgSubtleSecondary)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
@@ -646,6 +655,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
forceJumpToBottomVisibility = true,
knockRequestsBannerView = {},
onThreadsListClick = {},
onGalleryEventItemClick = { _, _ -> false },
)
}
@@ -699,6 +709,7 @@ internal fun MessagesViewA11yPreview() = ElementPreview {
onJoinCallClick = {},
onViewAllPinnedMessagesClick = {},
onThreadsListClick = {},
onGalleryEventItemClick = { _, _ -> false },
forceJumpToBottomVisibility = true,
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.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
@@ -37,6 +39,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.canBeCopied
import io.element.android.features.messages.impl.timeline.model.event.canBeForwarded
import io.element.android.features.messages.impl.timeline.model.event.canReact
import io.element.android.features.messages.impl.timeline.model.event.captionOrNull
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
@@ -198,9 +201,12 @@ class DefaultActionListPresenter(
add(TimelineItemAction.Forward)
}
if (timelineItem.isEditable && usersEventPermissions.canSendMessage) {
if (timelineItem.content is TimelineItemEventContentWithAttachment) {
if (timelineItem.content is TimelineItemEventContentWithAttachment ||
timelineItem.content is TimelineItemGalleryContent ||
timelineItem.content is TimelineItemAttachmentsContent) {
// Caption
if (timelineItem.content.caption == null) {
val caption = timelineItem.content.captionOrNull()
if (caption == null) {
add(TimelineItemAction.AddCaption)
} else {
add(TimelineItemAction.EditCaption)
@@ -225,7 +231,7 @@ class DefaultActionListPresenter(
}
if (timelineItem.content.canBeCopied()) {
add(TimelineItemAction.CopyText)
} else if ((timelineItem.content as? TimelineItemEventContentWithAttachment)?.caption.isNullOrBlank().not()) {
} else if (timelineItem.content.captionOrNull().isNullOrBlank().not()) {
add(TimelineItemAction.CopyCaption)
}
if (timelineItem.isRemote) {
@@ -63,9 +63,11 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser
import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction
import io.element.android.features.messages.impl.timeline.components.MessageShieldView
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@@ -299,6 +301,12 @@ private fun MessageSummary(
is TimelineItemImageContent -> {
content = { ContentForBody(event.content.bestDescription) }
}
is TimelineItemGalleryContent -> {
content = { ContentForBody(event.content.body) }
}
is TimelineItemAttachmentsContent -> {
content = { ContentForBody(event.content.body) }
}
is TimelineItemStickerContent -> {
content = { ContentForBody(event.content.bestDescription) }
}
@@ -23,4 +23,5 @@ sealed interface AttachmentsPreviewEvent {
data object ResetImageEdits : AttachmentsPreviewEvent
data class UpdateImageCropRect(val cropRect: NormalizedCropRect) : AttachmentsPreviewEvent
data object ClearImageEditError : AttachmentsPreviewEvent
data class SetCurrentCarouselIndex(val index: Int) : AttachmentsPreviewEvent
}
@@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
import kotlinx.collections.immutable.ImmutableList
@ContributesNode(RoomScope::class)
@AssistedInject
@@ -42,7 +43,7 @@ class AttachmentsPreviewNode(
private val enterpriseService: EnterpriseService,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val attachment: Attachment,
val attachments: ImmutableList<Attachment>,
val timelineMode: Timeline.Mode,
val inReplyToEventId: EventId?,
) : NodeInputs
@@ -54,7 +55,7 @@ class AttachmentsPreviewNode(
}
private val presenter = presenterFactory.create(
attachment = inputs.attachment,
attachments = inputs.attachments,
timelineMode = inputs.timelineMode,
onDoneListener = onDoneListener,
inReplyToEventId = inputs.inReplyToEventId,
@@ -12,6 +12,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -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.AttachmentImageEdits
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.safeDelete
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.textcomposer.model.TextEditorState
import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
@@ -59,7 +59,7 @@ import java.io.File
@AssistedInject
class AttachmentsPreviewPresenter(
@Assisted private val attachment: Attachment,
@Assisted private val attachments: ImmutableList<Attachment>,
@Assisted private val onDoneListener: OnDoneListener,
@Assisted private val timelineMode: Timeline.Mode,
@Assisted private val inReplyToEventId: EventId?,
@@ -68,7 +68,6 @@ class AttachmentsPreviewPresenter(
private val temporaryUriDeleter: TemporaryUriDeleter,
private val attachmentImageEditor: AttachmentImageEditor,
private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory,
private val videoCompressionPresetSelector: VideoCompressionPresetSelector,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val dispatchers: CoroutineDispatchers,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
@@ -76,13 +75,18 @@ class AttachmentsPreviewPresenter(
@AssistedFactory
interface Factory {
fun create(
attachment: Attachment,
attachments: ImmutableList<Attachment>,
timelineMode: Timeline.Mode,
onDoneListener: OnDoneListener,
inReplyToEventId: EventId?,
): AttachmentsPreviewPresenter
}
data class AttachmentAndEdits(
val attachment: Attachment,
val edits: AttachmentImageEdits,
)
private val mediaSender = mediaSenderFactory.create(timelineMode)
@Composable
@@ -92,14 +96,13 @@ class AttachmentsPreviewPresenter(
val sendActionState = remember {
mutableStateOf<SendActionState>(SendActionState.Idle)
}
val originalLocalMedia = remember { (attachment as Attachment.Media).localMedia }
var currentAttachment by remember { mutableStateOf(attachment) }
val originalLocalMedia = remember { (attachments.first() as Attachment.Media).localMedia }
var currentAttachment by remember { mutableStateOf(attachments.first()) }
var canEditImage by remember { mutableStateOf(originalLocalMedia.info.canEditImage()) }
var imageEditorState by remember { mutableStateOf<AttachmentImageEditorState?>(null) }
var appliedImageEdits by remember { mutableStateOf(AttachmentImageEdits()) }
var isApplyingImageEdits by remember { mutableStateOf(false) }
var displayImageEditError by remember { mutableStateOf(false) }
var editedTempFile by remember { mutableStateOf<File?>(null) }
var editedTempFiles by remember { mutableStateOf<Map<Int, File>>(emptyMap()) }
val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false)
val textEditorState by rememberUpdatedState(
@@ -108,13 +111,23 @@ class AttachmentsPreviewPresenter(
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) }
val mediaAttachment = currentAttachment as Attachment.Media
val firstMediaAttachment = attachments.first() as Attachment.Media
val mediaOptimizationSelectorPresenter = remember {
mediaOptimizationSelectorPresenterFactory.create(
localMedia = mediaAttachment.localMedia,
sendAsFile = mediaAttachment.sendAsFile,
localMedia = firstMediaAttachment.localMedia,
sendAsFile = firstMediaAttachment.sendAsFile,
)
}
val mediaOptimizationSelectorState by rememberUpdatedState(mediaOptimizationSelectorPresenter.present())
@@ -123,52 +136,51 @@ class AttachmentsPreviewPresenter(
var displayFileTooLargeError by remember { mutableStateOf(false) }
LaunchedEffect(
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")
LaunchedEffect(mediaOptimizationSelectorState.displayMediaSelectorViews, mediaOptimizationSelectorState.selectedVideoPreset) {
if (mediaOptimizationSelectorState.displayMediaSelectorViews == false &&
preprocessMediaJob == null &&
imageEditorState == null &&
!isApplyingImageEdits) {
if (mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo() && mediaOptimizationSelectorState.videoSizeEstimations.dataOrNull() == null) {
Timber.d("Waiting for video size estimations to be able to select the best video compression preset before pre-processing the media")
return@LaunchedEffect
}
val config = getAutoPreprocessMediaOptimizationConfig(
mediaAttachment = mediaAttachment,
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
) ?: return@LaunchedEffect
preprocessMediaJob = coroutineScope.preProcessAttachment(
attachment = currentAttachment,
mediaOptimizationConfig = config,
displayProgress = false,
sendActionState = sendActionState,
!isApplyingImageEdits
) {
val config = MediaOptimizationConfig(
compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled ?: mediaOptimizationConfigProvider.get().compressImages,
videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: mediaOptimizationConfigProvider.get().videoCompressionPreset,
)
preprocessMediaJob?.cancel()
preprocessMediaJob = coroutineScope.launch(dispatchers.io) {
preProcessAttachments(
attachments = attachmentsAndEdits.map { it.attachment },
mediaOptimizationConfig = config,
displayProgress = false,
sendActionState = sendActionState,
)
}
}
}
LaunchedEffect(originalLocalMedia) {
canEditImage = originalLocalMedia.info.canEditImage() || attachmentImageEditor.canEdit(originalLocalMedia)
LaunchedEffect(currentIndex) {
val currentMedia = (attachments.getOrNull(currentIndex) as? Attachment.Media)?.localMedia
if (currentMedia != null) {
canEditImage = currentMedia.info.canEditImage() || attachmentImageEditor.canEdit(currentMedia)
}
}
val maxUploadSize = mediaOptimizationSelectorState.maxUploadSize.dataOrNull()
LaunchedEffect(maxUploadSize) {
// Check file upload size if the media won't be processed for upload
val isImageFile = mediaAttachment.localMedia.info.isImageAttachment()
val isVideoFile = mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo()
if (maxUploadSize != null && !(isImageFile || isVideoFile)) {
if (maxUploadSize != null) {
// If file size is not known, we're permissive and allow sending. The SDK will cancel the upload if needed.
val fileSize = mediaAttachment.localMedia.info.fileSize ?: 0L
if (maxUploadSize < fileSize) {
displayFileTooLargeError = true
displayFileTooLargeError = attachments.any { attachment ->
when (attachment) {
is Attachment.Media -> {
val isImageFile = attachment.localMedia.info.isImageAttachment()
val isVideoFile = attachment.localMedia.info.mimeType.isMimeTypeVideo()
if (isImageFile || isVideoFile) {
false
} else {
val fileSize = attachment.localMedia.info.fileSize ?: 0L
maxUploadSize < fileSize
}
}
}
}
}
}
@@ -192,12 +204,29 @@ class AttachmentsPreviewPresenter(
compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled == true,
videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: VideoCompressionPreset.STANDARD,
)
preprocessMediaJob = preProcessAttachment(
attachment = currentAttachment,
mediaOptimizationConfig = config,
displayProgress = true,
sendActionState = sendActionState,
preprocessMediaJob = coroutineScope.launch(dispatchers.io) {
preProcessAttachments(
attachments = attachmentsAndEdits.map { it.attachment },
mediaOptimizationConfig = config,
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
@@ -206,35 +235,27 @@ class AttachmentsPreviewPresenter(
}
// Wait until the media is ready to be uploaded
val mediaUploadInfo = observableSendState.firstInstanceOf<SendActionState.Sending.ReadyToUpload>().mediaInfo
val allMediaUploadInfos = observableSendState.firstInstanceOf<SendActionState.Sending.ReadyToUpload>().mediaInfos
// Pre-processing is done, send the attachment
val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder)
.takeIf { it.isNotEmpty() }
val editedTempFileToDelete = editedTempFile
editedTempFile = null
// If we're supposed to send the media as a background job, we can dismiss this screen already
if (coroutineContext.isActive) {
onDoneListener()
}
val editedTempFilesToDelete = editedTempFiles
editedTempFiles = emptyMap()
// Send the media using the session coroutine scope so it doesn't matter if this screen or the chat one are closed
sessionCoroutineScope.launch(dispatchers.io) {
try {
sendPreProcessedMedia(
mediaUploadInfo = mediaUploadInfo,
caption = caption,
sendActionState = sendActionState,
dismissAfterSend = false,
inReplyToEventId = inReplyToEventId,
)
} finally {
editedTempFileToDelete?.safeDelete()
// Clean up the pre-processed media after it's been sent
mediaSender.cleanUp()
}
sendGalleryPreProcessed(
mediaUploadInfos = allMediaUploadInfos,
caption = caption,
sendActionState = sendActionState,
inReplyToEventId = inReplyToEventId,
)
// Clean up the pre-processed media after it's been sent
mediaSender.cleanUp()
editedTempFilesToDelete.values.forEach { it.safeDelete() }
}
}
}
@@ -251,7 +272,11 @@ class AttachmentsPreviewPresenter(
ongoingSendAttachmentJob.value?.cancel()
// Dismiss the screen
dismiss(sendActionState, editedTempFile)
dismiss(
attachments = attachmentsAndEdits.map { it.attachment },
sendActionState = sendActionState,
editedTempFiles = editedTempFiles,
)
}
AttachmentsPreviewEvent.CancelAndClearSendState -> {
// Cancel media sending
@@ -260,22 +285,23 @@ class AttachmentsPreviewPresenter(
ongoingSendAttachmentJob.value = null
}
val mediaUploadInfo = sendActionState.value.mediaUploadInfo()
sendActionState.value = if (mediaUploadInfo != null) {
SendActionState.Sending.ReadyToUpload(mediaUploadInfo)
val mediaUploadInfoList = sendActionState.value.mediaUploadInfoList()
sendActionState.value = if (mediaUploadInfoList != null) {
SendActionState.Sending.ReadyToUpload(mediaUploadInfoList)
} else {
SendActionState.Idle
}
}
AttachmentsPreviewEvent.OpenImageEditor -> {
val resolvedCanEditImage = canEditImage || originalLocalMedia.info.canEditImage()
val currentLocalMedia = (attachments.getOrNull(currentIndex) as? Attachment.Media)?.localMedia ?: return
val resolvedCanEditImage = canEditImage || currentLocalMedia.info.canEditImage()
if (resolvedCanEditImage) {
preprocessMediaJob?.cancel()
preprocessMediaJob = null
resetPreparedMedia(sendActionState)
imageEditorState = AttachmentImageEditorState(
localMedia = originalLocalMedia,
edits = appliedImageEdits,
localMedia = currentLocalMedia,
edits = attachmentsAndEdits.get(currentIndex).edits,
previewDebug = false,
)
}
@@ -315,10 +341,15 @@ class AttachmentsPreviewPresenter(
AttachmentsPreviewEvent.ApplyImageEdits -> {
val pendingState = imageEditorState ?: return
if (!pendingState.edits.hasChanges) {
editedTempFile?.safeDelete()
editedTempFile = null
appliedImageEdits = pendingState.edits
currentAttachment = Attachment.Media(originalLocalMedia)
editedTempFiles[currentIndex]?.safeDelete()
editedTempFiles = editedTempFiles - currentIndex
currentAttachment = attachmentsAndEdits[currentIndex].attachment
attachmentsAndEdits = attachmentsAndEdits.toMutableList().also {
it[currentIndex] = AttachmentAndEdits(
currentAttachment,
pendingState.edits,
)
}.toImmutableList()
imageEditorState = null
resetPreparedMedia(sendActionState)
return
@@ -328,16 +359,21 @@ class AttachmentsPreviewPresenter(
coroutineScope.launch {
val result = withContext(dispatchers.io) {
attachmentImageEditor.exportEdits(
localMedia = originalLocalMedia,
localMedia = pendingState.localMedia,
edits = pendingState.edits,
)
}
result.fold(
onSuccess = { editedMedia ->
editedTempFile?.safeDelete()
editedTempFile = editedMedia.file
appliedImageEdits = pendingState.edits
editedTempFiles[currentIndex]?.safeDelete()
editedTempFiles = editedTempFiles + (currentIndex to editedMedia.file)
currentAttachment = Attachment.Media(editedMedia.localMedia)
attachmentsAndEdits = attachmentsAndEdits.toMutableList().also {
it[currentIndex] = AttachmentAndEdits(
currentAttachment,
pendingState.edits,
)
}.toImmutableList()
imageEditorState = null
resetPreparedMedia(sendActionState)
},
@@ -352,11 +388,14 @@ class AttachmentsPreviewPresenter(
AttachmentsPreviewEvent.ClearImageEditError -> {
displayImageEditError = false
}
is AttachmentsPreviewEvent.SetCurrentCarouselIndex -> {
currentIndex = event.index
}
}
}
return AttachmentsPreviewState(
attachment = currentAttachment,
attachments = attachmentsAndEdits.map { it.attachment }.toImmutableList(),
imageEditorState = imageEditorState,
canEditImage = canEditImage,
isApplyingImageEdits = isApplyingImageEdits,
@@ -365,92 +404,62 @@ class AttachmentsPreviewPresenter(
textEditorState = textEditorState,
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
displayFileTooLargeError = displayFileTooLargeError,
currentIndex = currentIndex,
eventSink = ::handleEvent,
)
}
private suspend fun getAutoPreprocessMediaOptimizationConfig(
mediaAttachment: Attachment.Media,
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,
private suspend fun preProcessAttachments(
attachments: List<Attachment>,
mediaOptimizationConfig: MediaOptimizationConfig,
displayProgress: Boolean,
sendActionState: MutableState<SendActionState>,
) {
sendActionState.value = SendActionState.Sending.Processing(displayProgress = displayProgress)
mediaSender.preProcessMedia(
uri = mediaAttachment.localMedia.uri,
mimeType = mediaAttachment.localMedia.info.mimeType,
mediaOptimizationConfig = mediaOptimizationConfig,
).fold(
onSuccess = { mediaUploadInfo ->
Timber.d("Media ${mediaUploadInfo.file.path.orEmpty().hash()} finished processing, it's now ready to upload")
sendActionState.value = SendActionState.Sending.ReadyToUpload(mediaUploadInfo)
},
onFailure = {
Timber.e(it, "Failed to pre-process media")
if (it is CancellationException) {
throw it
} else {
sendActionState.value = SendActionState.Failure(it, null)
}
}
)
}
private fun dismiss(
sendActionState: MutableState<SendActionState>,
editedTempFile: File?,
) {
// Delete the temporary file
when (attachment) {
is Attachment.Media -> {
temporaryUriDeleter.delete(attachment.localMedia.uri)
sendActionState.value.mediaUploadInfo()?.let { data ->
cleanUp(data)
val mediaUploadInfos = mutableListOf<MediaUploadInfo>()
for (attachment in attachments) {
when (attachment) {
is Attachment.Media -> {
mediaSender.preProcessMedia(
uri = attachment.localMedia.uri,
mimeType = attachment.localMedia.info.mimeType,
mediaOptimizationConfig = mediaOptimizationConfig,
).fold(
onSuccess = { mediaUploadInfo ->
Timber.d("Media ${mediaUploadInfo.file.path.orEmpty().hash()} finished processing")
mediaUploadInfos.add(mediaUploadInfo)
},
onFailure = {
Timber.e(it, "Failed to pre-process media")
if (it is CancellationException) {
throw it
} else {
sendActionState.value = SendActionState.Failure(it, emptyList())
return
}
}
)
}
}
}
editedTempFile?.safeDelete()
// Reset the sendActionState to ensure that dialog is closed before the screen
sendActionState.value = SendActionState.Sending.ReadyToUpload(mediaUploadInfos)
}
private fun dismiss(
attachments: List<Attachment>,
sendActionState: MutableState<SendActionState>,
editedTempFiles: Map<Int, File> = emptyMap(),
) {
for (attachment in attachments) {
when (attachment) {
is Attachment.Media -> {
temporaryUriDeleter.delete(attachment.localMedia.uri)
}
}
}
val uploadInfos = (sendActionState.value as? SendActionState.Sending.ReadyToUpload)?.mediaInfos
uploadInfos?.forEach { cleanUp(it) }
editedTempFiles.values.forEach { it.safeDelete() }
sendActionState.value = SendActionState.Done
onDoneListener()
}
@@ -464,41 +473,45 @@ class AttachmentsPreviewPresenter(
}
private fun resetPreparedMedia(sendActionState: MutableState<SendActionState>) {
sendActionState.value.mediaUploadInfo()?.let(::cleanUp)
sendActionState.value.mediaUploadInfoList()?.forEach(::cleanUp)
mediaSender.cleanUp()
sendActionState.value = SendActionState.Idle
}
private suspend fun sendPreProcessedMedia(
mediaUploadInfo: MediaUploadInfo,
private suspend fun sendGalleryPreProcessed(
mediaUploadInfos: List<MediaUploadInfo>,
caption: String?,
sendActionState: MutableState<SendActionState>,
dismissAfterSend: Boolean,
inReplyToEventId: EventId?,
) = runCatchingExceptions {
sendActionState.value = SendActionState.Sending.Uploading(mediaUploadInfo)
mediaSender.sendPreProcessedMedia(
mediaUploadInfo = mediaUploadInfo,
caption = caption,
formattedCaption = null,
inReplyToEventId = inReplyToEventId,
).getOrThrow()
if (mediaUploadInfos.size == 1) {
sendActionState.value = SendActionState.Sending.Uploading(mediaUploadInfos)
mediaSender.sendPreProcessedMedia(
mediaUploadInfo = mediaUploadInfos.first(),
caption = caption,
formattedCaption = null,
inReplyToEventId = inReplyToEventId,
).getOrThrow()
} else {
mediaSender.sendGallery(
mediaUploadInfos = mediaUploadInfos,
caption = caption,
formattedCaption = null,
inReplyToEventId = inReplyToEventId,
).getOrThrow()
}
}.fold(
onSuccess = {
cleanUp(mediaUploadInfo)
// Reset the sendActionState to ensure that dialog is closed before the screen
mediaUploadInfos.forEach { cleanUp(it) }
sendActionState.value = SendActionState.Done
if (dismissAfterSend) {
onDoneListener()
}
onDoneListener()
},
onFailure = { error ->
Timber.e(error, "Failed to send attachment")
if (error is CancellationException) {
throw error
} else {
sendActionState.value = SendActionState.Failure(error, mediaUploadInfo)
sendActionState.value = SendActionState.Failure(error, mediaUploadInfos)
}
}
)
@@ -14,9 +14,10 @@ import io.element.android.features.messages.impl.attachments.preview.imageeditor
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.textcomposer.model.TextEditorState
import kotlinx.collections.immutable.ImmutableList
data class AttachmentsPreviewState(
val attachment: Attachment,
val attachments: ImmutableList<Attachment>,
val imageEditorState: AttachmentImageEditorState?,
val canEditImage: Boolean,
val isApplyingImageEdits: Boolean,
@@ -25,8 +26,12 @@ data class AttachmentsPreviewState(
val textEditorState: TextEditorState,
val mediaOptimizationSelectorState: MediaOptimizationSelectorState,
val displayFileTooLargeError: Boolean,
val currentIndex: Int,
val eventSink: (AttachmentsPreviewEvent) -> Unit,
)
) {
val isGallery: Boolean get() = attachments.size > 1
val totalCount: Int get() = attachments.size
}
@Immutable
sealed interface SendActionState {
@@ -35,17 +40,17 @@ sealed interface SendActionState {
@Immutable
sealed interface Sending : SendActionState {
data class Processing(val displayProgress: Boolean) : Sending
data class ReadyToUpload(val mediaInfo: MediaUploadInfo) : Sending
data class Uploading(val mediaUploadInfo: MediaUploadInfo) : Sending
data class ReadyToUpload(val mediaInfos: List<MediaUploadInfo>) : Sending
data class Uploading(val mediaInfos: List<MediaUploadInfo>) : Sending
}
data class Failure(val error: Throwable, val mediaUploadInfo: MediaUploadInfo?) : SendActionState
data class Failure(val error: Throwable, val mediaInfos: List<MediaUploadInfo>) : SendActionState
data object Done : SendActionState
fun mediaUploadInfo(): MediaUploadInfo? = when (this) {
is Sending.ReadyToUpload -> mediaInfo
is Sending.Uploading -> mediaUploadInfo
is Failure -> mediaUploadInfo
fun mediaUploadInfoList(): List<MediaUploadInfo>? = when (this) {
is Sending.ReadyToUpload -> mediaInfos
is Sending.Uploading -> mediaInfos
is Failure -> mediaInfos
else -> null
}
}
@@ -41,9 +41,9 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider<Attachment
)
),
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Processing(displayProgress = true)),
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.ReadyToUpload(aMediaUploadInfo())),
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(aMediaUploadInfo())),
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"), aMediaUploadInfo())),
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.ReadyToUpload(listOf(aMediaUploadInfo()))),
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(listOf(aMediaUploadInfo()))),
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"), listOf(aMediaUploadInfo()))),
anAttachmentsPreviewState(
imageEditorState = anAttachmentImageEditorState(),
),
@@ -72,9 +72,12 @@ fun anAttachmentsPreviewState(
imageEditorState: AttachmentImageEditorState? = null,
mediaOptimizationSelectorState: MediaOptimizationSelectorState = aMediaOptimisationSelectorState(),
displayFileTooLargeError: Boolean = false,
currentIndex: Int = 0,
) = AttachmentsPreviewState(
attachment = Attachment.Media(
localMedia = LocalMedia("file://path".toUri(), mediaInfo),
attachments = persistentListOf(
Attachment.Media(
localMedia = LocalMedia("file://path".toUri(), mediaInfo),
),
),
imageEditorState = imageEditorState,
canEditImage = true,
@@ -84,6 +87,37 @@ fun anAttachmentsPreviewState(
textEditorState = textEditorState,
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
displayFileTooLargeError = displayFileTooLargeError,
currentIndex = currentIndex,
eventSink = {}
)
fun anAttachmentsPreviewGalleryState(
mediaInfo: MediaInfo = anImageMediaInfo(),
textEditorState: TextEditorState = aTextEditorStateMarkdown(),
sendActionState: SendActionState = SendActionState.Idle,
mediaOptimizationSelectorState: MediaOptimizationSelectorState = aMediaOptimisationSelectorState(),
currentIndex: Int = 0,
) = AttachmentsPreviewState(
attachments = persistentListOf(
Attachment.Media(
localMedia = LocalMedia("file://path1".toUri(), mediaInfo),
),
Attachment.Media(
localMedia = LocalMedia("file://path2".toUri(), mediaInfo),
),
Attachment.Media(
localMedia = LocalMedia("file://path3".toUri(), mediaInfo),
),
),
imageEditorState = null,
canEditImage = false,
isApplyingImageEdits = false,
displayImageEditError = false,
sendActionState = sendActionState,
textEditorState = textEditorState,
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
displayFileTooLargeError = false,
currentIndex = currentIndex,
eventSink = {}
)
@@ -9,11 +9,16 @@
package io.element.android.features.messages.impl.attachments.preview
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.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -21,12 +26,17 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
@@ -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.ListItem
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Switch
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.theme.floatingDateBadgeBackground
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
@@ -77,6 +89,20 @@ import io.element.android.libraries.ui.utils.formatter.rememberFileSizeFormatter
import io.element.android.wysiwyg.display.TextDisplay
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlin.time.Duration.Companion.milliseconds
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
@@ -252,6 +278,7 @@ private fun AttachmentSendStateView(
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
private fun AttachmentPreviewContent(
state: AttachmentsPreviewState,
@@ -266,16 +293,75 @@ private fun AttachmentPreviewContent(
) {
Box(
modifier = Modifier
.weight(1f),
contentAlignment = Alignment.Center
.fillMaxSize()
.weight(1f)
) {
when (val attachment = state.attachment) {
is Attachment.Media -> {
localMediaRenderer.Render(attachment.localMedia)
if (state.isGallery) {
val pagerState = rememberPagerState(
initialPage = state.currentIndex,
pageCount = { state.attachments.size },
)
var isPillVisible by remember { mutableStateOf(true) }
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
beyondViewportPageCount = 1,
contentPadding = PaddingValues(horizontal = 20.dp),
pageSpacing = 10.dp,
) { page ->
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
val attachment = state.attachments[page]
when (attachment) {
is Attachment.Media -> {
localMediaRenderer.Render(attachment.localMedia)
}
}
}
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.isScrollInProgress }
.collectLatest { isScrolling ->
if (isScrolling) {
isPillVisible = true
} else {
delay(2000.milliseconds)
isPillVisible = false
}
}
}
androidx.compose.animation.AnimatedVisibility(
visible = isPillVisible,
enter = fadeIn(animationSpec = tween(150)),
exit = fadeOut(animationSpec = tween(300)),
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 8.dp),
) {
GalleryCarouselPill(
currentIndex = pagerState.currentPage + 1,
totalCount = state.totalCount,
)
}
LaunchedEffect(pagerState.currentPage) {
state.eventSink(AttachmentsPreviewEvent.SetCurrentCarouselIndex(pagerState.currentPage))
}
} else {
val firstAttachment = state.attachments.first()
when (firstAttachment) {
is Attachment.Media -> {
localMediaRenderer.Render(firstAttachment.localMedia)
}
}
}
}
val mediaInfo = (state.attachment as? Attachment.Media)?.localMedia?.info
val mediaInfo = (state.attachments.first() as? Attachment.Media)?.localMedia?.info
if (mediaInfo?.isImageAttachment() == true) {
ImageOptimizationSelector(state.mediaOptimizationSelectorState)
} else if (mediaInfo?.mimeType?.isMimeTypeVideo() == true) {
@@ -485,16 +571,16 @@ private fun AttachmentsPreviewBottomActions(
internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = ElementPreviewDark {
AttachmentsPreviewView(
state = state,
localMediaRenderer = object : LocalMediaRenderer {
@Composable
override fun Render(localMedia: LocalMedia) {
Image(
painter = painterResource(id = CommonDrawables.sample_background),
modifier = Modifier.fillMaxSize(),
contentDescription = null,
)
}
}
localMediaRenderer = SingleItemPreviewRenderer,
)
}
@Preview
@Composable
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 = {},
knockRequestsBannerView = {},
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.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.Attachment.Media
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
import io.element.android.features.messages.impl.draft.ComposerDraftService
import io.element.android.features.messages.impl.messagecomposer.suggestions.RoomAliasSuggestionsDataSource
@@ -48,6 +47,8 @@ import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
@@ -133,6 +134,7 @@ class MessageComposerPresenter(
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
private val notificationConversationService: NotificationConversationService,
private val slashCommandService: SlashCommandService,
private val featureFlagService: FeatureFlagService,
) : Presenter<MessageComposerState> {
@AssistedFactory
interface Factory {
@@ -177,10 +179,19 @@ class MessageComposerPresenter(
canShareLocation.value = locationService.isServiceAvailable()
}
val isSendGalleryMessagesEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.SendGalleryMessages)
.collectAsState(initial = false)
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType ->
handlePickedMedia(uri, mimeType)
}
val filesPicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri, mimeType ->
val galleryMultiMediaPicker = mediaPickerProvider.registerGalleryMultiPicker { uris ->
handlePickedMediaList(uris)
}
val filesPicker = mediaPickerProvider.registerFileMultiPicker(AnyMimeTypes) { uris ->
handlePickedMediaList(uris, sendAsFile = true)
}
val fileSinglePicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri, mimeType ->
handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream, sendAsFile = true)
}
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
@@ -266,7 +277,7 @@ class MessageComposerPresenter(
is MessageComposerEvent.SendUri -> {
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
sessionCoroutineScope.sendAttachment(
attachment = Media(
attachment = Attachment.Media(
localMedia = localMediaFactory.createFromUri(
uri = event.uri,
mimeType = null,
@@ -289,11 +300,19 @@ class MessageComposerPresenter(
MessageComposerEvent.DismissAttachmentMenu -> showAttachmentSourcePicker = false
MessageComposerEvent.PickAttachmentSource.FromGallery -> localCoroutineScope.launch {
showAttachmentSourcePicker = false
galleryMediaPicker.launch()
if (isSendGalleryMessagesEnabled) {
galleryMultiMediaPicker.launch()
} else {
galleryMediaPicker.launch()
}
}
MessageComposerEvent.PickAttachmentSource.FromFiles -> localCoroutineScope.launch {
showAttachmentSourcePicker = false
filesPicker.launch()
if (isSendGalleryMessagesEnabled) {
filesPicker.launch()
} else {
fileSinglePicker.launch()
}
}
MessageComposerEvent.PickAttachmentSource.PhotoFromCamera -> localCoroutineScope.launch {
showAttachmentSourcePicker = false
@@ -623,6 +642,30 @@ class MessageComposerPresenter(
messageComposerContext.composerMode = MessageComposerMode.Normal
}
private fun handlePickedMediaList(
uris: List<Uri>,
sendAsFile: Boolean = false,
) {
if (uris.isEmpty()) return
if (uris.size == 1) {
handlePickedMedia(uris.first(), sendAsFile = sendAsFile)
return
}
val attachments = uris.map { uri ->
val localMedia = localMediaFactory.createFromUri(
uri = uri,
mimeType = null,
name = null,
formattedFileSize = null,
)
Attachment.Media(localMedia, sendAsFile = sendAsFile)
}.toImmutableList()
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
navigator.navigateToPreviewAttachments(attachments, inReplyToEventId)
messageComposerContext.composerMode = MessageComposerMode.Normal
}
private suspend fun sendMedia(
uri: Uri,
mimeType: String,
@@ -53,6 +53,7 @@ class PinnedMessagesListNode(
) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator {
interface Callback : Plugin {
fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean)
fun handleGalleryItemClick(event: TimelineItem.Event, galleryItemIndex: Int, canUseOverlay: Boolean)
fun navigateToRoomMemberDetails(userId: UserId)
fun viewInTimeline(eventId: EventId)
fun handlePermalinkClick(data: PermalinkData.RoomLink)
@@ -119,6 +120,9 @@ class PinnedMessagesListNode(
onEventClick = {
callback.handleEventClick(it, canUseOverlay)
},
onGalleryItemClick = { event, index ->
callback.handleGalleryItemClick(event, index, canUseOverlay)
},
onUserDataClick = { callback.navigateToRoomMemberDetails(it.userId) },
onLinkClick = { link -> onLinkClick(context, link.url) },
onLinkLongClick = {
@@ -60,6 +60,7 @@ fun PinnedMessagesListView(
state: PinnedMessagesListState,
onBackClick: () -> Unit,
onEventClick: (event: TimelineItem.Event) -> Unit,
onGalleryItemClick: (event: TimelineItem.Event, index: Int) -> Unit,
onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
@@ -81,6 +82,7 @@ fun PinnedMessagesListView(
PinnedMessagesListContent(
state = state,
onEventClick = onEventClick,
onGalleryItemClick = onGalleryItemClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
@@ -111,6 +113,7 @@ private fun PinnedMessagesListTopBar(
private fun PinnedMessagesListContent(
state: PinnedMessagesListState,
onEventClick: (event: TimelineItem.Event) -> Unit,
onGalleryItemClick: (event: TimelineItem.Event, index: Int) -> Unit,
onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
@@ -131,6 +134,7 @@ private fun PinnedMessagesListContent(
state = state,
displayThreadSummaries = state.displayThreadSummaries,
onEventClick = onEventClick,
onGalleryItemClick = onGalleryItemClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
@@ -169,6 +173,7 @@ private fun PinnedMessagesListLoaded(
state: PinnedMessagesListState.Filled,
displayThreadSummaries: Boolean,
onEventClick: (event: TimelineItem.Event) -> Unit,
onGalleryItemClick: (event: TimelineItem.Event, index: Int) -> Unit,
onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
@@ -226,6 +231,7 @@ private fun PinnedMessagesListLoaded(
},
onLinkLongClick = onLinkLongClick,
onContentClick = onEventClick,
onGalleryItemClick = onGalleryItemClick,
onLongClick = ::onMessageLongClick,
displayThreadSummaries = displayThreadSummaries,
inReplyToClick = {},
@@ -245,6 +251,7 @@ private fun PinnedMessagesListLoaded(
event = event,
timelineProtectionState = state.timelineProtectionState,
onContentClick = { onEventClick(event) },
onGalleryItemClick = { index -> onGalleryItemClick(event, index) },
onLongClick = { onMessageLongClick(event) },
onLinkClick = { link ->
state.linkState.eventSink(LinkEvent.OnLinkClick(link))
@@ -268,6 +275,7 @@ private fun TimelineItemEventContentViewWrapper(
event: TimelineItem.Event,
timelineProtectionState: TimelineProtectionState,
onContentClick: () -> Unit,
onGalleryItemClick: (index: Int) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onLongClick: (() -> Unit)?,
@@ -285,6 +293,7 @@ private fun TimelineItemEventContentViewWrapper(
content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onGalleryItemClick = onGalleryItemClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
eventSink = { },
@@ -304,6 +313,7 @@ internal fun PinnedMessagesListViewPreview(@PreviewParameter(PinnedMessagesListS
state = state,
onBackClick = {},
onEventClick = { },
onGalleryItemClick = { _, _ -> },
onUserDataClick = {},
onLinkClick = {},
onLinkLongClick = {},
@@ -126,6 +126,7 @@ class ThreadedMessagesNode(
interface Callback : Plugin {
fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean
fun handleGalleryItemClick(event: TimelineItem.Event, galleryItemIndex: Int, canUseOverlay: Boolean): Boolean
fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
fun navigateToRoomMemberDetails(userId: UserId)
fun handlePermalinkClick(data: PermalinkData)
@@ -289,6 +290,9 @@ class ThreadedMessagesNode(
}
} == true
},
onGalleryEventItemClick = { event, index ->
callback.handleGalleryItemClick(event, index, canUseOverlay)
},
onUserDataClick = callback::navigateToRoomMemberDetails,
onLinkClick = { url, customTab ->
onLinkClick(
@@ -97,6 +97,7 @@ fun TimelineView(
onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit,
onContentClick: (TimelineItem.Event) -> Unit,
onGalleryItemClick: ((TimelineItem.Event, Int) -> Unit),
onMessageLongClick: (TimelineItem.Event) -> Unit,
onSwipeToReply: (TimelineItem.Event) -> Unit,
onReactionClick: (emoji: String, TimelineItem.Event) -> Unit,
@@ -178,6 +179,7 @@ fun TimelineView(
onLinkClick = onLinkClick,
onLinkLongClick = ::onLinkLongClick,
onContentClick = onContentClick,
onGalleryItemClick = onGalleryItemClick,
onLongClick = onMessageLongClick,
inReplyToClick = ::inReplyToClick,
onReactionClick = onReactionClick,
@@ -428,6 +430,7 @@ internal fun TimelineViewPreview(
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
onGalleryItemClick = { _, _ -> },
forceJumpToBottomVisibility = true,
)
}
@@ -49,6 +49,7 @@ internal fun TimelineViewMessageShieldPreview() = ElementPreview {
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
onGalleryItemClick = { _, _ -> },
forceJumpToBottomVisibility = true,
)
}
@@ -33,6 +33,7 @@ internal fun ATimelineItemEventRow(
isLastOutgoingMessage = isLastOutgoingMessage,
displayThreadSummaries = displayThreadSummaries,
onEventClick = {},
onGalleryItemClick = {},
onLongClick = {},
onLinkClick = {},
onLinkLongClick = {},
@@ -0,0 +1,138 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.timeline.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.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
@@ -145,6 +147,7 @@ fun TimelineItemEventRow(
isLastOutgoingMessage: Boolean,
displayThreadSummaries: Boolean,
onEventClick: () -> Unit,
onGalleryItemClick: ((Int) -> Unit),
onLongClick: () -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
@@ -165,13 +168,14 @@ fun TimelineItemEventRow(
content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
onContentClick = onContentClick,
onGalleryItemClick = onGalleryItemClick,
onLongClick = onLongClick,
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
eventSink = eventSink,
modifier = contentModifier,
onContentLayoutChange = onContentLayoutChange
onContentLayoutChange = onContentLayoutChange,
)
},
) {
@@ -776,6 +780,8 @@ private fun MessageEventBubbleContent(
val timestampPosition = when (val content = event.content) {
is TimelineItemImageContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
is TimelineItemVideoContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
is TimelineItemGalleryContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Below
is TimelineItemAttachmentsContent -> TimestampPosition.Below
is TimelineItemStickerContent -> TimestampPosition.Overlay
is TimelineItemLocationContent -> {
val content = content.ensureActiveLiveLocation()
@@ -790,6 +796,8 @@ private fun MessageEventBubbleContent(
val paddingBehaviour = when (event.content) {
is TimelineItemImageContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
is TimelineItemVideoContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
is TimelineItemGalleryContent -> ContentPadding.CaptionedMedia
is TimelineItemAttachmentsContent -> ContentPadding.CaptionedMedia
is TimelineItemStickerContent,
is TimelineItemLocationContent -> ContentPadding.Media
else -> ContentPadding.Textual
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@PreviewsDayNight
@Composable
internal fun TimelineItemEventRowWithGalleryPreview(
@PreviewParameter(TimelineItemEventContentForGalleryViewProvider::class) content: TimelineItemEventContent,
) = ElementPreview {
Column {
sequenceOf(false, true).forEach { isMine ->
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = isMine,
content = content,
groupPosition = TimelineItemGroupPosition.Last,
),
)
}
}
}
@@ -69,6 +69,7 @@ fun TimelineItemGroupedEventsRow(
eventSink = eventSink,
modifier = contentModifier,
onContentClick = null,
onGalleryItemClick = {},
onLongClick = null,
onContentLayoutChange = onContentLayoutChange
)
@@ -140,6 +141,7 @@ private fun TimelineItemGroupedEventsRowContent(
eventSink = eventSink,
modifier = contentModifier,
onContentClick = null,
onGalleryItemClick = {},
onLongClick = null,
onContentLayoutChange = onContentLayoutChange
)
@@ -177,6 +179,7 @@ private fun TimelineItemGroupedEventsRowContent(
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onContentClick = onClick,
onGalleryItemClick = { _, _ -> },
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,
onReactionClick = onReactionClick,
@@ -64,6 +64,7 @@ internal fun TimelineItemRow(
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onContentClick: (TimelineItem.Event) -> Unit,
onGalleryItemClick: (TimelineItem.Event, Int) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
@@ -80,12 +81,13 @@ internal fun TimelineItemRow(
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onContentClick = { onContentClick(event) },
onGalleryItemClick = { index -> onGalleryItemClick(event, index) },
onLongClick = { onLongClick(event) },
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
eventSink = eventSink,
modifier = contentModifier,
onContentLayoutChange = onContentLayoutChange
onContentLayoutChange = onContentLayoutChange,
)
},
) {
@@ -178,6 +180,7 @@ internal fun TimelineItemRow(
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
onSwipeToReply = { onSwipeToReply(timelineItem) },
onGalleryItemClick = { index -> onGalleryItemClick(timelineItem, index) },
eventSink = eventSink,
eventContentView = { contentModifier, onContentLayoutChange ->
eventContentView(timelineItem, contentModifier, onContentLayoutChange)
@@ -74,6 +74,7 @@ fun TimelineItemStateEventRow(
onShowContentClick = {},
eventSink = eventSink,
onContentClick = null,
onGalleryItemClick = {},
onLongClick = null,
modifier = Modifier.defaultTimelineContentPadding()
)
@@ -0,0 +1,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.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.rememberPresenter
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@@ -40,6 +42,7 @@ fun TimelineItemEventContentView(
content: TimelineItemEventContent,
hideMediaContent: Boolean,
onContentClick: (() -> Unit)?,
onGalleryItemClick: ((Int) -> Unit),
onLongClick: (() -> Unit)?,
onShowContentClick: () -> Unit,
onLinkClick: (Link) -> Unit,
@@ -90,6 +93,23 @@ fun TimelineItemEventContentView(
onContentLayoutChange = onContentLayoutChange,
modifier = modifier,
)
is TimelineItemGalleryContent -> TimelineItemGalleryView(
content = content,
onGalleryItemClick = { index -> onGalleryItemClick(index) },
onLongClick = onLongClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier,
)
is TimelineItemAttachmentsContent -> TimelineItemAttachmentsListView(
content = content,
onGalleryItemClick = { index -> onGalleryItemClick(index) },
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onContentLayoutChange = {},
modifier = modifier,
)
is TimelineItemStickerContent -> TimelineItemStickerView(
content = content,
hideMediaContent = hideMediaContent,
@@ -0,0 +1,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.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.widthIn
import androidx.compose.foundation.shape.CircleShape
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.runtime.getValue
@@ -31,13 +36,13 @@ import androidx.compose.runtime.setValue
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.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
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.libraries.designsystem.components.blurhash.blurHashBackground
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.PreviewsDayNight
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.ui.strings.CommonStrings
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.link.Link
@@ -79,6 +84,7 @@ fun TimelineItemVideoView(
onLinkLongClick: (Link) -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
isGalleryVideo: Boolean = false,
) {
val isTalkbackActive = isTalkbackActive()
val a11yLabel = stringResource(CommonStrings.common_video)
@@ -130,16 +136,71 @@ fun TimelineItemVideoView(
onState = { isLoaded = it is AsyncImagePainter.State.Success },
)
Box(
modifier = Modifier.roundedBackground(),
contentAlignment = Alignment.Center,
) {
Image(
imageVector = CompoundIcons.PlaySolid(),
contentDescription = stringResource(id = CommonStrings.a11y_play),
colorFilter = ColorFilter.tint(Color.White),
modifier = Modifier.semantics { hideFromAccessibility() }
)
if (isGalleryVideo) {
Box(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxSize()
.align(Alignment.BottomCenter)
.background(
brush = Brush.verticalGradient(
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 = {},
onLinkLongClick = {},
onContentLayoutChange = {},
isGalleryVideo = false,
)
}
@@ -199,6 +261,7 @@ internal fun TimelineItemVideoViewHideMediaContentPreview() = ElementPreview {
onLinkClick = {},
onLinkLongClick = {},
onContentLayoutChange = {},
isGalleryVideo = false,
)
}
@@ -14,10 +14,14 @@ import androidx.core.text.toSpannable
import dev.zacsweers.metro.Inject
import io.element.android.features.location.api.Location
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.features.messages.impl.timeline.model.event.AttachmentItem
import io.element.android.features.messages.impl.timeline.model.event.GalleryItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent
@@ -35,6 +39,8 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryItemType
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
@@ -263,6 +269,144 @@ class TimelineItemContentMessageFactory(
isEdited = content.isEdited,
)
}
is GalleryMessageType -> {
val dom = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
val formattedCaption = dom?.let(::parseHtml)
?: messageType.body.withLinks()
val galleryItems = messageType.items.mapNotNull { item ->
when (item) {
is GalleryItemType.Image -> {
GalleryItem(
filename = item.content.filename,
mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream,
mediaSource = item.content.source,
type = GalleryItem.Type.Image,
thumbnailSource = item.content.info?.thumbnailSource,
width = item.content.info?.width?.toInt(),
height = item.content.info?.height?.toInt(),
thumbnailWidth = item.content.info?.thumbnailInfo?.width?.toInt(),
thumbnailHeight = item.content.info?.thumbnailInfo?.height?.toInt(),
blurhash = item.content.info?.blurhash,
)
}
is GalleryItemType.Video -> {
GalleryItem(
filename = item.content.filename,
mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream,
mediaSource = item.content.source,
type = GalleryItem.Type.Video,
thumbnailSource = item.content.info?.thumbnailSource,
width = item.content.info?.width?.toInt(),
height = item.content.info?.height?.toInt(),
thumbnailWidth = item.content.info?.thumbnailInfo?.width?.toInt(),
thumbnailHeight = item.content.info?.thumbnailInfo?.height?.toInt(),
blurhash = item.content.info?.blurhash,
duration = item.content.info?.duration ?: Duration.ZERO,
)
}
is GalleryItemType.Audio -> {
GalleryItem(
filename = item.content.filename,
mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream,
mediaSource = item.content.source,
type = GalleryItem.Type.Audio,
thumbnailSource = null,
width = null,
height = null,
thumbnailWidth = null,
thumbnailHeight = null,
blurhash = null,
duration = item.content.info?.duration ?: Duration.ZERO,
)
}
is GalleryItemType.File -> {
GalleryItem(
filename = item.content.filename,
mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream,
mediaSource = item.content.source,
type = GalleryItem.Type.File,
thumbnailSource = item.content.info?.thumbnailSource,
width = null,
height = null,
thumbnailWidth = null,
thumbnailHeight = null,
blurhash = null,
)
}
is GalleryItemType.Other -> null
}
}
val hasPreviews = galleryItems.any { it.thumbnailSource != null }
val 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 -> {
val body = messageType.body.trimEnd()
TimelineItemTextContent(
@@ -9,9 +9,11 @@
package io.element.android.features.messages.impl.timeline.groups
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@@ -52,6 +54,8 @@ internal fun TimelineItem.Event.canBeGrouped(): Boolean {
is TimelineItemTextBasedContent,
is TimelineItemEncryptedContent,
is TimelineItemImageContent,
is TimelineItemGalleryContent,
is TimelineItemAttachmentsContent,
is TimelineItemStickerContent,
is TimelineItemFileContent,
is TimelineItemVideoContent,
@@ -10,7 +10,9 @@ package io.element.android.features.messages.impl.timeline.model
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.timeline.components.MessageShieldData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
@@ -124,6 +126,8 @@ sealed interface TimelineItem {
is TimelineItemStickerContent -> content.formattedCaption == null && content.caption == null
is TimelineItemImageContent -> content.formattedCaption == null && content.caption == null
is TimelineItemVideoContent -> content.formattedCaption == null && content.caption == null
is TimelineItemGalleryContent -> false
is TimelineItemAttachmentsContent -> false
else -> true
}
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.matrix.api.media.MediaSource
import kotlinx.collections.immutable.ImmutableList
data class TimelineItemAttachmentsContent(
val body: String,
val caption: String?,
val formattedCaption: CharSequence?,
override val isEdited: Boolean,
val attachments: ImmutableList<AttachmentItem>,
) : TimelineItemEventContent, TimelineItemEventMutableContent {
override val type: String = "TimelineItemAttachmentsContent"
val showCaption = caption != null
val hasPreviews = attachments.any { it.thumbnailSource != null }
}
data class AttachmentItem(
val filename: String,
val mimeType: String,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val fileSize: Long?,
val formattedFileSize: String,
val fileExtension: String,
)
@@ -56,7 +56,9 @@ fun TimelineItemEventContent.canBeForwarded(): Boolean =
is TimelineItemAudioContent,
is TimelineItemVideoContent,
is TimelineItemLocationContent,
is TimelineItemVoiceContent -> true
is TimelineItemVoiceContent,
is TimelineItemGalleryContent,
is TimelineItemAttachmentsContent -> true
// Stickers can't be forwarded (yet) so we don't show the option
// See https://github.com/element-hq/element-x-android/issues/2161
is TimelineItemStickerContent -> false
@@ -78,7 +80,9 @@ fun TimelineItemEventContent.canReact(): Boolean =
is TimelineItemLocationContent,
is TimelineItemPollContent,
is TimelineItemVoiceContent,
is TimelineItemVideoContent -> true
is TimelineItemVideoContent,
is TimelineItemGalleryContent,
is TimelineItemAttachmentsContent -> true
is TimelineItemStateContent,
is TimelineItemRedactedContent,
is TimelineItemLegacyCallInviteContent,
@@ -99,6 +103,18 @@ fun TimelineItemEventContent.isEdited(): Boolean = when (this) {
*/
fun TimelineItemEventContent.isRedacted(): Boolean = this is TimelineItemRedactedContent
/**
* Returns the caption text for content types that support captions.
* Gallery and attachments content types have captions but don't implement
* [TimelineItemEventContentWithAttachment].
*/
fun TimelineItemEventContent.captionOrNull(): String? = when (this) {
is TimelineItemEventContentWithAttachment -> caption
is TimelineItemGalleryContent -> caption
is TimelineItemAttachmentsContent -> caption
else -> null
}
fun TimelineItemEventContentWithAttachment.duration(): Duration? {
return when (this) {
is TimelineItemAudioContent -> duration
@@ -13,8 +13,11 @@ import android.text.style.StyleSpan
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import kotlinx.collections.immutable.toImmutableList
import org.jsoup.nodes.Document
import kotlin.time.Duration
class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEventContent> {
override val values = sequenceOf(
@@ -111,3 +114,81 @@ fun aTimelineItemStateEventContent(
) = TimelineItemStateEventContent(
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
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@@ -40,6 +42,8 @@ fun TimelineItem.mustBeProtected(): Boolean {
} else {
when (content) {
is TimelineItemImageContent,
is TimelineItemGalleryContent,
is TimelineItemAttachmentsContent,
is TimelineItemVideoContent,
is TimelineItemStickerContent -> true
is TimelineItemAudioContent,
@@ -11,10 +11,12 @@ package io.element.android.features.messages.impl.utils.messagesummary
import android.content.Context
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@@ -56,6 +58,16 @@ class DefaultMessageSummaryFormatter(
is TimelineItemVideoContent -> context.getString(CommonStrings.common_video)
is TimelineItemFileContent -> context.getString(CommonStrings.common_file)
is TimelineItemAudioContent -> context.getString(CommonStrings.common_audio)
is TimelineItemGalleryContent -> context.getString(CommonStrings.common_gallery)
is TimelineItemAttachmentsContent -> {
val count = content.attachments.size
val extensions = content.attachments.take(3).joinToString { it.fileExtension }
if (count <= 3) {
extensions
} else {
"$extensions +${count - 3}"
}
}
is TimelineItemLegacyCallInviteContent -> context.getString(CommonStrings.common_unsupported_call)
is TimelineItemRtcNotificationContent -> when (content.state) {
is RtcNotificationState.Declined -> {
@@ -685,6 +685,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setMessagesView(
onBackClick: () -> Unit = EnsureNeverCalled(),
onRoomDetailsClick: () -> Unit = EnsureNeverCalled(),
onEventClick: (isLive: Boolean, event: TimelineItem.Event) -> Boolean = EnsureNeverCalledWithTwoParamsAndResult(),
onGalleryEventItemClick: (event: TimelineItem.Event, index: Int) -> Boolean = EnsureNeverCalledWithTwoParamsAndResult(),
onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
onLinkClick: (String, Boolean) -> Unit = EnsureNeverCalledWithTwoParams(),
onSendLocationClick: () -> Unit = EnsureNeverCalled(),
@@ -701,6 +702,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setMessagesView(
onBackClick = onBackClick,
onRoomDetailsClick = onRoomDetailsClick,
onEventContentClick = onEventClick,
onGalleryEventItemClick = onGalleryEventItemClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
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.assertIsSimilarTo
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.fixtures.aMediaAttachment
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.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.GalleryItemInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
@@ -70,6 +70,7 @@ import io.element.android.tests.testutils.testCoroutineDispatchers
import io.mockk.every
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
@@ -121,8 +122,8 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false))
initialState.eventSink(AttachmentsPreviewEvent.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = true))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo)))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(listOf(mediaUploadInfo)))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
sendFileResult.assertions().isCalledOnce()
onDoneListener.assertions().isCalledOnce()
@@ -157,8 +158,8 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
advanceUntilIdle()
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false))
initialState.eventSink(AttachmentsPreviewEvent.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo)))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(listOf(mediaUploadInfo)))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
sendFileResult.assertions().isCalledOnce()
onDoneListener.assertions().isCalledOnce()
@@ -193,8 +194,8 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
// Pre-processing finishes
processLatch.complete(Unit)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = true))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo)))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(listOf(mediaUploadInfo)))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
sendFileResult.assertions().isCalledOnce()
onDoneListener.assertions().isCalledOnce()
@@ -411,18 +412,14 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.eventSink(AttachmentsPreviewEvent.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo))
// Check that the onDoneListener is called so the screen would be dismissed
onDoneListenerResult.assertions().isCalledOnce()
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo)))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(listOf(mediaUploadInfo)))
val failureState = awaitItem()
assertThat(failureState.sendActionState).isEqualTo(SendActionState.Failure(failure, mediaUploadInfo))
assertThat(failureState.sendActionState).isEqualTo(SendActionState.Failure(failure, listOf(mediaUploadInfo)))
sendFileResult.assertions().isCalledOnce()
failureState.eventSink(AttachmentsPreviewEvent.CancelAndClearSendState)
val clearedState = awaitLastSequentialItem()
assertThat(clearedState.sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo))
assertThat(clearedState.sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo)))
}
}
@@ -444,15 +441,12 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.eventSink(AttachmentsPreviewEvent.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo)))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(listOf(mediaUploadInfo)))
initialState.eventSink(AttachmentsPreviewEvent.CancelAndClearSendState)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo)))
// The sending is cancelled and the state is kept at ReadyToUpload
ensureAllEventsConsumed()
// Check that the onDoneListener is called so the screen would be dismissed
onDoneListenerResult.assertions().isCalledOnce()
}
}
@@ -464,7 +458,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
val maxUploadSize = 999L // Set a max upload size smaller than the file size
val presenter = createAttachmentsPreviewPresenter(
localMedia = localMedia,
attachments = listOf(Attachment.Media(localMedia)),
room = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply {
sendFileLambda = { _, _, _, _, _ ->
@@ -503,7 +497,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
val localMedia = aLocalMedia(uri = Uri.EMPTY, mediaInfo = aVideoMediaInfo())
val presenter = createAttachmentsPreviewPresenter(
localMedia = localMedia,
attachments = listOf(Attachment.Media(localMedia)),
room = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply {
sendFileLambda = { _, _, _, _, _ ->
@@ -584,7 +578,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
assertThat(awaitItem().isApplyingImageEdits).isTrue()
val appliedState = awaitItem()
assertThat((appliedState.attachment as Attachment.Media).localMedia.uri).isEqualTo(editedUri)
assertThat((appliedState.attachments.first() as Attachment.Media).localMedia.uri).isEqualTo(editedUri)
assertThat(appliedState.imageEditorState).isNull()
assertThat(appliedState.isApplyingImageEdits).isFalse()
}
@@ -601,7 +595,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
bottom = 0.9f,
)
val presenter = createAttachmentsPreviewPresenter(
localMedia = originalLocalMedia,
attachments = listOf(Attachment.Media(originalLocalMedia)),
displayMediaQualitySelectorViews = true,
attachmentImageEditor = FakeAttachmentImageEditor {
Result.success(
@@ -629,7 +623,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
flippedState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits)
val appliedState = consumeItemsUntilPredicate { !it.isApplyingImageEdits && it.imageEditorState == null }.last()
assertThat((appliedState.attachment as Attachment.Media).localMedia.uri).isEqualTo(editedUri)
assertThat((appliedState.attachments.first() as Attachment.Media).localMedia.uri).isEqualTo(editedUri)
appliedState.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
val reopenedState = consumeItemsUntilPredicate { it.imageEditorState != null }.last()
@@ -673,21 +667,26 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
}
}
@Test
fun `present - sendAsFile attachment is pre-processed without image compression`() = runTest {
// Even though the user has enabled "Optimize media quality" globally, picking the file
// through the Files picker (sendAsFile = true) must skip compression. Regression test
// for https://github.com/element-hq/element-x-android/issues/6365
val mediaPreProcessor = FakeMediaPreProcessor()
val presenter = createAttachmentsPreviewPresenter(
localMedia = aLocalMedia(mockMediaUrl, anImageMediaInfo()),
sendAsFile = true,
attachments = listOf(
Attachment.Media(
localMedia = aLocalMedia(mockMediaUrl, anImageMediaInfo()),
sendAsFile = true,
)
),
mediaPreProcessor = mediaPreProcessor,
// Selector views are hidden in the sendAsFile flow, which triggers the auto pre-process path.
displayMediaQualitySelectorViews = false,
mediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(
config = MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
compressImages = false,
videoCompressionPreset = VideoCompressionPreset.HIGH,
)
),
)
@@ -772,8 +771,9 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
fileExtension = "png",
),
)
val presenter = createAttachmentsPreviewPresenter(localMedia = localMedia)
val presenter = createAttachmentsPreviewPresenter(
attachments = listOf(Attachment.Media(localMedia)),
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.canEditImage).isTrue()
@@ -795,7 +795,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
),
)
val presenter = createAttachmentsPreviewPresenter(
localMedia = localMedia,
attachments = listOf(Attachment.Media(localMedia)),
attachmentImageEditor = FakeAttachmentImageEditor(
canEditResult = true,
) {
@@ -822,8 +822,12 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
fun `present - sendAsFile video is pre-processed with best fitting preset`() = runTest {
val mediaPreProcessor = FakeMediaPreProcessor()
val presenter = createAttachmentsPreviewPresenter(
localMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()),
sendAsFile = true,
attachments = listOf(
Attachment.Media(
localMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()),
sendAsFile = true,
)
),
mediaPreProcessor = mediaPreProcessor,
// Selector views are hidden in the sendAsFile flow, which triggers the auto pre-process path.
displayMediaQualitySelectorViews = false,
@@ -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(
localMedia: LocalMedia = aLocalMedia(
uri = mockMediaUrl,
attachments: List<Attachment> = listOf(
aMediaAttachment(
aLocalMedia(
uri = mockMediaUrl,
)
),
),
sendAsFile: Boolean = false,
room: JoinedRoom = FakeJoinedRoom(),
timelineMode: Timeline.Mode = Timeline.Mode.Live,
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
@@ -890,17 +945,21 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
),
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
attachmentImageEditor: AttachmentImageEditor = FakeAttachmentImageEditor {
Result.success(
EditedLocalMedia(
localMedia = localMedia.copy(uri = Uri.parse("file:///tmp/default-edited.jpeg")),
file = File("/tmp/default-edited.jpeg"),
val localMediaResult = (attachments.first() as? Attachment.Media)?.localMedia?.copy(uri = Uri.parse("file:///tmp/default-edited.jpeg"))
if (localMediaResult != null) {
Result.success(
EditedLocalMedia(
localMedia = localMediaResult,
file = File("/tmp/default-edited.jpeg"),
)
)
)
} else {
Result.failure(IllegalStateException("Check test values"))
}
},
videoCompressionPresetSelector: VideoCompressionPresetSelector = VideoCompressionPresetSelector(),
): AttachmentsPreviewPresenter {
return AttachmentsPreviewPresenter(
attachment = aMediaAttachment(localMedia, sendAsFile = sendAsFile),
attachments = attachments.toImmutableList(),
onDoneListener = onDoneListener,
mediaSenderFactory = MediaSenderFactory { timelineMode ->
DefaultMediaSender(
@@ -918,7 +977,6 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
sessionCoroutineScope = this,
dispatchers = testCoroutineDispatchers(),
mediaOptimizationSelectorPresenterFactory = mediaOptimizationSelectorPresenterFactory,
videoCompressionPresetSelector = videoCompressionPresetSelector,
timelineMode = timelineMode,
inReplyToEventId = null,
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
@@ -11,28 +11,27 @@ package io.element.android.features.messages.impl.attachments
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.attachments.preview.SendActionState
import io.element.android.features.messages.impl.attachments.preview.aMediaUploadInfo
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import org.junit.Test
class SendActionStateTest {
@Test
fun `mediaUploadInfo() should return the value from Uploading class`() {
val mediaUploadInfo: MediaUploadInfo = aMediaUploadInfo()
val state: SendActionState = SendActionState.Sending.Uploading(mediaUploadInfo = aMediaUploadInfo())
assertThat(state.mediaUploadInfo()).isEqualTo(mediaUploadInfo)
fun `mediaUploadInfoList() should return the value from Uploading class`() {
val data = listOf(aMediaUploadInfo())
val state: SendActionState = SendActionState.Sending.Uploading(mediaInfos = data)
assertThat(state.mediaUploadInfoList()).isEqualTo(data)
}
@Test
fun `mediaUploadInfo() should return the value from ReadyToUpload class`() {
val mediaUploadInfo: MediaUploadInfo = aMediaUploadInfo()
val state: SendActionState = SendActionState.Sending.ReadyToUpload(mediaInfo = aMediaUploadInfo())
assertThat(state.mediaUploadInfo()).isEqualTo(mediaUploadInfo)
fun `mediaUploadInfoList() should return the value from ReadyToUpload class`() {
val data = listOf(aMediaUploadInfo())
val state: SendActionState = SendActionState.Sending.ReadyToUpload(mediaInfos = data)
assertThat(state.mediaUploadInfoList()).isEqualTo(data)
}
@Test
fun `mediaUploadInfo() should return the value from Failure class`() {
val mediaUploadInfo: MediaUploadInfo = aMediaUploadInfo()
val state: SendActionState = SendActionState.Failure(error = IllegalStateException("An error"), mediaUploadInfo = aMediaUploadInfo())
assertThat(state.mediaUploadInfo()).isEqualTo(mediaUploadInfo)
fun `mediaUploadInfoList() should return the value from Failure class`() {
val data = listOf(aMediaUploadInfo())
val state: SendActionState = SendActionState.Failure(error = IllegalStateException("An error"), mediaInfos = data)
assertThat(state.mediaUploadInfoList()).isEqualTo(data)
}
}
@@ -24,6 +24,7 @@ import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter
import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper
import io.element.android.features.messages.impl.utils.TextPillificationHelper
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
@@ -274,6 +275,7 @@ class MessageComposerPresenterSlashCommandTest {
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
threadRoot: ThreadId? = null,
slashCommandService: SlashCommandService = FakeSlashCommandService(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
) = MessageComposerPresenter(
navigator = navigator,
sessionCoroutineScope = this,
@@ -312,6 +314,7 @@ class MessageComposerPresenterSlashCommandTest {
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
notificationConversationService = notificationConversationService,
slashCommandService = slashCommandService,
featureFlagService = featureFlagService,
).apply {
isTesting = true
showTextFormatting = isRichTextEditorEnabled
@@ -34,6 +34,7 @@ import io.element.android.features.messages.impl.utils.TextPillificationHelper
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
@@ -1534,6 +1535,7 @@ class MessageComposerPresenterTest : RobolectricTest() {
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
threadRoot: ThreadId? = null,
slashCommandService: SlashCommandService = FakeSlashCommandService(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
) = MessageComposerPresenter(
navigator = navigator,
sessionCoroutineScope = this,
@@ -1572,6 +1574,7 @@ class MessageComposerPresenterTest : RobolectricTest() {
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
notificationConversationService = notificationConversationService,
slashCommandService = slashCommandService,
featureFlagService = featureFlagService,
).apply {
isTesting = true
showTextFormatting = isRichTextEditorEnabled
@@ -27,6 +27,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
@@ -96,6 +97,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setPinnedMessagesListView(
state: PinnedMessagesListState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onEventClick: (event: TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
onGalleryItemClick: (event: TimelineItem.Event, index: Int) -> Unit = EnsureNeverCalledWithTwoParams(),
onUserDataClick: (MatrixUser) -> Unit = EnsureNeverCalledWithParam(),
onLinkClick: (Link) -> Unit = EnsureNeverCalledWithParam(),
onLinkLongClick: (Link) -> Unit = EnsureNeverCalledWithParam(),
@@ -105,6 +107,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setPinnedMessagesListView(
state = state,
onBackClick = onBackClick,
onEventClick = onEventClick,
onGalleryItemClick = onGalleryItemClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
@@ -216,6 +216,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setTimelineView(
onReactionLongClick: (emoji: String, TimelineItem.Event) -> Unit = EnsureNeverCalledWithTwoParams(),
onMoreReactionsClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
onReadReceiptClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
onGalleryItemClick: (TimelineItem.Event, Int) -> Unit = EnsureNeverCalledWithTwoParams(),
forceJumpToBottomVisibility: Boolean = false,
) {
setSafeContent(clearAndroidUiDispatcher = true) {
@@ -231,6 +232,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setTimelineView(
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
onGalleryItemClick = onGalleryItemClick,
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
)
}
@@ -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_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_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_not_found">"Room not found"</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.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
@@ -104,6 +105,9 @@ class DefaultPinnedMessagesBannerFormatter(
is OtherMessageType -> {
messageType.body
}
is GalleryMessageType -> {
messageType.body.prefixWith(CommonStrings.common_gallery)
}
is NoticeMessageType -> {
messageType.body
}
@@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent
@@ -167,6 +168,9 @@ class DefaultRoomLatestEventFormatter(
is OtherMessageType -> {
messageType.body
}
is GalleryMessageType -> {
messageType.body.prefixWith(sp.getString(CommonStrings.common_gallery))
}
is NoticeMessageType -> {
messageType.body
}
@@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventTimeline
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
@@ -141,6 +142,7 @@ class DefaultPinnedMessagesBannerFormatterTest : RobolectricTest() {
AudioMessageType(body, null, null, MediaSource("url"), null),
VoiceMessageType(body, null, null, MediaSource("url"), null, null),
ImageMessageType(body, null, null, MediaSource("url"), null),
GalleryMessageType(body, null, emptyList()),
StickerMessageType(body, null, null, MediaSource("url"), null),
FileMessageType(body, null, null, MediaSource("url"), null),
LocationMessageType(body, "geo:1,2", null, null),
@@ -163,6 +165,7 @@ class DefaultPinnedMessagesBannerFormatterTest : RobolectricTest() {
is VideoMessageType,
is AudioMessageType,
is ImageMessageType,
is GalleryMessageType,
is StickerMessageType,
is FileMessageType,
is LocationMessageType -> AnnotatedString::class.java
@@ -181,6 +184,7 @@ class DefaultPinnedMessagesBannerFormatterTest : RobolectricTest() {
is AudioMessageType -> "Audio: Shared body"
is VoiceMessageType -> "Voice message"
is ImageMessageType -> "Image: Shared body"
is GalleryMessageType -> "Gallery: Shared body"
is StickerMessageType -> "Sticker: Shared body"
is FileMessageType -> "File: Shared body"
is LocationMessageType -> "Shared location: Shared body"
@@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
@@ -195,6 +196,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
AudioMessageType(body, null, null, MediaSource("url"), null),
VoiceMessageType(body, null, null, MediaSource("url"), null, null),
ImageMessageType(body, null, null, MediaSource("url"), null),
GalleryMessageType(body, null, emptyList()),
StickerMessageType(body, null, null, MediaSource("url"), null),
FileMessageType(body, null, null, MediaSource("url"), null),
LocationMessageType(body, "geo:1,2", null, null),
@@ -227,6 +229,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
is AudioMessageType -> "Audio: Shared body"
is VoiceMessageType -> "Voice message"
is ImageMessageType -> "Image: Shared body"
is GalleryMessageType -> "Gallery: Shared body"
is StickerMessageType -> "Sticker: Shared body"
is FileMessageType -> "File: Shared body"
is LocationMessageType -> "Shared location"
@@ -247,6 +250,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
is TextMessageType -> false
is NoticeMessageType -> false
is OtherMessageType -> false
is GalleryMessageType -> true
}
if (shouldCreateAnnotatedString) {
assertWithMessage("$type doesn't produce an AnnotatedString")
@@ -264,6 +268,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
is AudioMessageType -> "$expectedPrefix: Audio: Shared body"
is VoiceMessageType -> "$expectedPrefix: Voice message"
is ImageMessageType -> "$expectedPrefix: Image: Shared body"
is GalleryMessageType -> "$expectedPrefix: Gallery: Shared body"
is StickerMessageType -> "$expectedPrefix: Sticker: Shared body"
is FileMessageType -> "$expectedPrefix: File: Shared body"
is LocationMessageType -> "$expectedPrefix: Shared location"
@@ -284,6 +289,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
is TextMessageType -> true
is NoticeMessageType -> true
is OtherMessageType -> true
is GalleryMessageType -> true
}
if (shouldCreateAnnotatedString) {
assertWithMessage("$type doesn't produce an AnnotatedString")
@@ -137,4 +137,11 @@ enum class FeatureFlags(
defaultValue = { false },
isFinished = false,
),
SendGalleryMessages(
key = "feature.send_gallery_messages",
title = "Send gallery messages",
description = "Allow sending multiple media items in a single message.",
defaultValue = { false },
isFinished = false,
),
}
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.media
import java.io.File
sealed interface GalleryItemInfo {
val file: File
data class Image(
override val file: File,
val imageInfo: ImageInfo,
val thumbnailFile: File?,
) : GalleryItemInfo
data class Video(
override val file: File,
val videoInfo: VideoInfo,
val thumbnailFile: File?,
) : GalleryItemInfo
data class Audio(
override val file: File,
val audioInfo: AudioInfo,
) : GalleryItemInfo
data class MediaFile(
override val file: File,
val fileInfo: FileInfo,
) : GalleryItemInfo
}
@@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.GalleryItemInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.media.VideoInfo
@@ -157,6 +158,13 @@ interface Timeline : AutoCloseable {
inReplyToEventId: EventId?,
): Result<MediaUploadHandler>
suspend fun sendGallery(
items: List<GalleryItemInfo>,
caption: String?,
formattedCaption: String?,
inReplyToEventId: EventId?,
): Result<MediaUploadHandler>
suspend fun redactEvent(eventOrTransactionId: EventOrTransactionId, reason: String?): Result<Unit>
suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result<Boolean>
@@ -102,6 +102,21 @@ data class TextMessageType(
val formatted: FormattedBody?
) : MessageType
data class GalleryMessageType(
val body: String,
val formatted: FormattedBody?,
val items: List<GalleryItemType>,
) : MessageType
@Immutable
sealed interface GalleryItemType {
data class Image(val content: ImageMessageType) : GalleryItemType
data class Audio(val content: AudioMessageType) : GalleryItemType
data class Video(val content: VideoMessageType) : GalleryItemType
data class File(val content: FileMessageType) : GalleryItemType
data class Other(val itemType: String, val body: String) : GalleryItemType
}
data class OtherMessageType(
val msgType: String,
val body: String,
@@ -0,0 +1,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)
MessageLikeEventContent.RoomEncrypted -> NotificationContent.MessageLike.RoomEncrypted
is MessageLikeEventContent.RoomMessage -> {
NotificationContent.MessageLike.RoomMessage(senderId, EventMessageMapper().mapMessageType(messageType))
NotificationContent.MessageLike.RoomMessage(
senderId,
EventMessageMapper().mapMessageType(messageType)
)
}
is MessageLikeEventContent.RoomRedaction -> NotificationContent.MessageLike.RoomRedaction(
redactedEventId = redactedEventId?.let(::EventId),
@@ -219,6 +219,7 @@ class JoinedRustRoom(
RoomMessageEventMessageType.IMAGE,
RoomMessageEventMessageType.VIDEO,
RoomMessageEventMessageType.AUDIO,
RoomMessageEventMessageType.GALLERY,
)
)
is CreateTimelineParams.Focused,
@@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.GalleryItemInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.media.VideoInfo
@@ -30,6 +31,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.impl.media.GalleryMediaUploadHandlerImpl
import io.element.android.libraries.matrix.impl.media.MediaUploadHandlerImpl
import io.element.android.libraries.matrix.impl.media.map
import io.element.android.libraries.matrix.impl.poll.toInner
@@ -67,6 +69,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.EditedContent
import org.matrix.rustcomponents.sdk.FormattedBody
import org.matrix.rustcomponents.sdk.GalleryUploadParameters
import org.matrix.rustcomponents.sdk.MessageFormat
import org.matrix.rustcomponents.sdk.PollData
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
@@ -535,6 +538,42 @@ class RustTimeline(
}
}
override suspend fun sendGallery(
items: List<GalleryItemInfo>,
caption: String?,
formattedCaption: String?,
inReplyToEventId: EventId?,
): Result<MediaUploadHandler> {
Timber.d("Sending gallery with ${items.size} items")
val allFiles = items.flatMap { item ->
when (item) {
is GalleryItemInfo.Image -> listOfNotNull(item.file, item.thumbnailFile)
is GalleryItemInfo.Video -> listOfNotNull(item.file, item.thumbnailFile)
is GalleryItemInfo.Audio -> listOf(item.file)
is GalleryItemInfo.MediaFile -> listOf(item.file)
}
}
return sendGalleryAttachment(allFiles) {
inner.sendGallery(
params = GalleryUploadParameters(
caption = caption,
formattedCaption = formattedCaption?.let {
FormattedBody(body = it, format = MessageFormat.Html)
},
mentions = null,
inReplyTo = inReplyToEventId?.value,
),
itemInfos = items.map { it.map() },
)
}
}
private fun sendGalleryAttachment(files: List<File>, handle: () -> org.matrix.rustcomponents.sdk.SendGalleryJoinHandle): Result<MediaUploadHandler> {
return runCatchingExceptions {
GalleryMediaUploadHandlerImpl(files, handle())
}
}
override suspend fun createPoll(
question: String,
answers: List<String>,
@@ -13,6 +13,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageT
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryItemType
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
@@ -31,12 +33,10 @@ import org.matrix.rustcomponents.sdk.MessageType
import org.matrix.rustcomponents.sdk.MsgLikeKind
import org.matrix.rustcomponents.sdk.use
import org.matrix.rustcomponents.sdk.FormattedBody as RustFormattedBody
import org.matrix.rustcomponents.sdk.GalleryItemType as RustGalleryItemType
import org.matrix.rustcomponents.sdk.MessageFormat as RustMessageFormat
import org.matrix.rustcomponents.sdk.MessageType as RustMessageType
// https://github.com/Johennes/matrix-spec-proposals/blob/johannes/msgtype-galleries/proposals/4274-inline-media-galleries.md#unstable-prefix
private const val MSG_TYPE_GALLERY_UNSTABLE = "dm.filament.gallery"
class EventMessageMapper {
private val inReplyToMapper by lazy { InReplyToMapper(TimelineEventContentMapper()) }
@@ -124,8 +124,64 @@ class EventMessageMapper {
OtherMessageType(type.msgtype, type.body)
}
is MessageType.Gallery -> {
// TODO expose the GalleryType.
OtherMessageType(MSG_TYPE_GALLERY_UNSTABLE, type.content.body)
GalleryMessageType(
body = type.content.body,
formatted = type.content.formatted?.map(),
items = type.content.itemtypes.map { mapGalleryItemType(it) },
)
}
}
private fun mapGalleryItemType(type: RustGalleryItemType): GalleryItemType = when (type) {
is RustGalleryItemType.Image -> {
GalleryItemType.Image(
content = ImageMessageType(
filename = type.content.filename,
caption = type.content.caption,
formattedCaption = type.content.formattedCaption?.map(),
source = type.content.source.map(),
info = type.content.info?.map(),
)
)
}
is RustGalleryItemType.Audio -> {
GalleryItemType.Audio(
content = AudioMessageType(
filename = type.content.filename,
caption = type.content.caption,
formattedCaption = type.content.formattedCaption?.map(),
source = type.content.source.map(),
info = type.content.info?.map(),
)
)
}
is RustGalleryItemType.Video -> {
GalleryItemType.Video(
content = VideoMessageType(
filename = type.content.filename,
caption = type.content.caption,
formattedCaption = type.content.formattedCaption?.map(),
source = type.content.source.map(),
info = type.content.info?.map(),
)
)
}
is RustGalleryItemType.File -> {
GalleryItemType.File(
content = FileMessageType(
filename = type.content.filename,
caption = type.content.caption,
formattedCaption = type.content.formattedCaption?.map(),
source = type.content.source.map(),
info = type.content.info?.map(),
)
)
}
is RustGalleryItemType.Other -> {
GalleryItemType.Other(
itemType = type.itemtype,
body = type.body,
)
}
}
}
@@ -13,6 +13,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.GalleryItemInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.media.VideoInfo
@@ -293,6 +294,29 @@ class FakeTimeline(
)
}
var sendGalleryLambda: (
items: List<GalleryItemInfo>,
caption: String?,
formattedCaption: String?,
inReplyToEventId: EventId?,
) -> Result<MediaUploadHandler> = { _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
override suspend fun sendGallery(
items: List<GalleryItemInfo>,
caption: String?,
formattedCaption: String?,
inReplyToEventId: EventId?,
): Result<MediaUploadHandler> = simulateLongTask {
sendGalleryLambda(
items,
caption,
formattedCaption,
inReplyToEventId,
)
}
var sendLocationLambda: (
body: String,
geoUri: String,
@@ -23,12 +23,23 @@ interface PickerProvider {
onResult: (Uri?) -> Unit
): PickerLauncher<PickVisualMediaRequest, Uri?>
@Composable
fun registerGalleryMultiPicker(
onResult: (uris: List<Uri>) -> Unit
): PickerLauncher<PickVisualMediaRequest, List<Uri>>
@Composable
fun registerFilePicker(
mimeType: String,
onResult: (uri: Uri?, mimeType: String?) -> Unit,
): PickerLauncher<String, Uri?>
@Composable
fun registerFileMultiPicker(
mimeType: String,
onResult: (uris: List<Uri>) -> Unit,
): PickerLauncher<Array<String>, List<Uri>>
@Composable
fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean>
@@ -15,6 +15,9 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Immutable
import io.element.android.libraries.core.mimetype.MimeTypes
// As per MSC4274, the recommended item cap is 60.
private const val MAX_GALLERY_ITEMS = 60
@Immutable
sealed interface PickerType<Input, Output> {
fun getContract(): ActivityResultContract<Input, Output>
@@ -34,6 +37,13 @@ sealed interface PickerType<Input, Output> {
}
}
data object ImageAndVideoMulti : PickerType<PickVisualMediaRequest, List<Uri>> {
override fun getContract() = ActivityResultContracts.PickMultipleVisualMedia(MAX_GALLERY_ITEMS)
override fun getDefaultRequest(): PickVisualMediaRequest {
return PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)
}
}
object Camera {
data class Photo(val destUri: Uri) : PickerType<Uri, Boolean> {
override fun getContract() = ActivityResultContracts.TakePicture()
@@ -56,4 +66,13 @@ sealed interface PickerType<Input, Output> {
return mimeType
}
}
data class FileMulti(val mimeType: String = MimeTypes.Any) : PickerType<Array<String>, List<Uri>> {
override fun getContract(): ActivityResultContract<Array<String>, List<Uri>> {
return ActivityResultContracts.OpenMultipleDocuments()
}
override fun getDefaultRequest(): Array<String> {
return arrayOf(mimeType)
}
}
}
@@ -80,6 +80,23 @@ class DefaultPickerProvider(
}
}
/**
* Remembers and returns a [PickerLauncher] for selecting multiple gallery items (images/videos).
* [onResult] will be called with the list of selected file [Uri]s.
*/
@Composable
override fun registerGalleryMultiPicker(
onResult: (uris: List<Uri>) -> Unit
): PickerLauncher<PickVisualMediaRequest, List<Uri>> {
return if (LocalInspectionMode.current) {
NoOpPickerLauncher { onResult(emptyList()) }
} else {
rememberPickerLauncher(type = PickerType.ImageAndVideoMulti) { uris ->
onResult(uris)
}
}
}
/**
* Remembers and returns a [PickerLauncher] for a file of a certain [mimeType] (any type of file, by default).
* [onResult] will be called with either the selected file's [Uri] or `null` if nothing was selected.
@@ -100,6 +117,24 @@ class DefaultPickerProvider(
}
}
/**
* Remembers and returns a [PickerLauncher] for selecting multiple files of a certain [mimeType].
* [onResult] will be called with the list of selected file URIs.
*/
@Composable
override fun registerFileMultiPicker(
mimeType: String,
onResult: (uris: List<Uri>) -> Unit,
): PickerLauncher<Array<String>, List<Uri>> {
return if (LocalInspectionMode.current) {
NoOpPickerLauncher { onResult(emptyList()) }
} else {
rememberPickerLauncher(type = PickerType.FileMulti(mimeType)) { uris ->
onResult(uris)
}
}
}
/**
* Remembers and returns a [PickerLauncher] for taking a photo with a camera app.
* @param [onResult] will be called with either the photo's [Uri] or `null` if nothing was selected.
@@ -113,7 +148,6 @@ class DefaultPickerProvider(
val tmpFile = remember { getTemporaryFile("photo.jpg") }
val tmpFileUri = remember(tmpFile) { getTemporaryUri(tmpFile) }
rememberPickerLauncher(type = PickerType.Camera.Photo(tmpFileUri)) { success ->
// Execute callback
onResult(if (success) tmpFileUri else null)
}
}
@@ -125,7 +159,6 @@ class DefaultPickerProvider(
*/
@Composable
override fun registerCameraVideoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean> {
// Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker
return if (LocalInspectionMode.current) {
NoOpPickerLauncher { onResult(null) }
} else {
@@ -30,11 +30,21 @@ class FakePickerProvider : PickerProvider {
return NoOpPickerLauncher { onResult(result) }
}
@Composable
override fun registerGalleryMultiPicker(onResult: (uris: List<Uri>) -> Unit): PickerLauncher<PickVisualMediaRequest, List<Uri>> {
return NoOpPickerLauncher { onResult(result?.let { listOf(it) } ?: emptyList()) }
}
@Composable
override fun registerFilePicker(mimeType: String, onResult: (Uri?, String?) -> Unit): PickerLauncher<String, Uri?> {
return NoOpPickerLauncher { onResult(result, this.mimeType) }
}
@Composable
override fun registerFileMultiPicker(mimeType: String, onResult: (uris: List<Uri>) -> Unit): PickerLauncher<Array<String>, List<Uri>> {
return NoOpPickerLauncher { onResult(result?.let { listOf(it) } ?: emptyList()) }
}
@Composable
override fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean> {
return NoOpPickerLauncher { onResult(result) }
@@ -61,5 +61,12 @@ interface MediaSender {
inReplyToEventId: EventId? = null,
): Result<Unit>
suspend fun sendGallery(
mediaUploadInfos: List<MediaUploadInfo>,
caption: String?,
formattedCaption: String?,
inReplyToEventId: EventId?,
): Result<Unit>
fun cleanUp()
}
@@ -10,6 +10,7 @@ package io.element.android.libraries.mediaupload.api
import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.GalleryItemInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import java.io.File
@@ -31,3 +32,30 @@ fun MediaUploadInfo.allFiles(): List<File> {
(this@allFiles as? MediaUploadInfo.Video)?.thumbnailFile,
)
}
fun MediaUploadInfo.toGalleryItemInfo(): GalleryItemInfo {
return when (this) {
is MediaUploadInfo.Image -> GalleryItemInfo.Image(
file = file,
imageInfo = imageInfo,
thumbnailFile = thumbnailFile,
)
is MediaUploadInfo.Video -> GalleryItemInfo.Video(
file = file,
videoInfo = videoInfo,
thumbnailFile = thumbnailFile,
)
is MediaUploadInfo.Audio -> GalleryItemInfo.Audio(
file = file,
audioInfo = audioInfo,
)
is MediaUploadInfo.VoiceMessage -> GalleryItemInfo.Audio(
file = file,
audioInfo = audioInfo,
)
is MediaUploadInfo.AnyFile -> GalleryItemInfo.MediaFile(
file = file,
fileInfo = fileInfo,
)
}
}
@@ -27,6 +27,7 @@ import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaSenderFactory
import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.api.toGalleryItemInfo
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import timber.log.Timber
@@ -167,6 +168,29 @@ class DefaultMediaSender(
.handleSendResult(mediaId(uri))
}
override suspend fun sendGallery(
mediaUploadInfos: List<MediaUploadInfo>,
caption: String?,
formattedCaption: String?,
inReplyToEventId: EventId?,
): Result<Unit> {
val galleryLogId = "gallery[${mediaUploadInfos.size} items]"
Timber.d("Sending $galleryLogId")
return getTimeline().flatMap { timeline ->
val galleryItems = mediaUploadInfos.map { it.toGalleryItemInfo() }
timeline.sendGallery(
items = galleryItems,
caption = caption,
formattedCaption = formattedCaption,
inReplyToEventId = inReplyToEventId,
)
}
.flatMapCatching { uploadHandler ->
uploadHandler.await()
}
.handleSendResult(galleryLogId)
}
private fun Result<Unit>.handleSendResult(mediaId: String) = this
.onFailure { error ->
val job = ongoingUploadJobs.remove(Job)
@@ -19,6 +19,7 @@ class FakeMediaSender(
private val sendPreProcessedMediaResult: () -> Result<Unit> = { lambdaError() },
private val sendMediaResult: () -> Result<Unit> = { lambdaError() },
private val sendVoiceMessageResult: () -> Result<Unit> = { lambdaError() },
private val sendGalleryResult: () -> Result<Unit> = { lambdaError() },
private val cleanUpResult: () -> Unit = { lambdaError() },
) : MediaSender {
override suspend fun preProcessMedia(
@@ -58,6 +59,15 @@ class FakeMediaSender(
return sendVoiceMessageResult()
}
override suspend fun sendGallery(
mediaUploadInfos: List<MediaUploadInfo>,
caption: String?,
formattedCaption: String?,
inReplyToEventId: EventId?,
): Result<Unit> {
return sendGalleryResult()
}
override fun cleanUp() {
cleanUpResult()
}
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.api
import android.os.Parcelable
import io.element.android.libraries.matrix.api.media.MediaSource
import kotlinx.parcelize.Parcelize
@Parcelize
data class GalleryItemData(
val filename: String,
val mimeType: String,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val type: Type,
) : Parcelable {
enum class Type {
Image,
Video,
Audio,
File,
}
}
@@ -13,6 +13,24 @@ import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.parcelize.Parcelize
@Parcelize
data class GalleryInfo(
val caption: String?,
val formattedCaption: CharSequence? = null,
val senderId: UserId?,
val senderName: String?,
val senderAvatar: String?,
val dateSent: String?,
val dateSentFull: String?,
val initialIndex: Int,
val isAttachment: Boolean,
) : Parcelable
@Parcelize
data class AvatarInfo(
val filename: String,
) : Parcelable
@Parcelize
data class MediaInfo(
val filename: String,
@@ -35,19 +35,29 @@ interface MediaViewerEntryPoint : FeatureEntryPoint {
fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean)
}
data class Params(
val mode: MediaViewerMode,
val eventId: EventId?,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val canShowInfo: Boolean,
) : NodeInputs
sealed interface Params : NodeInputs {
data class RoomMedia(
val mode: MediaViewerMode,
val eventId: EventId?,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
) : Params
data class EventGallery(
val eventId: EventId?,
val galleryInfo: GalleryInfo,
val galleryItems: List<GalleryItemData>,
) : Params
data class Avatar(
val avatarInfo: AvatarInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
) : Params
}
sealed interface MediaViewerMode : Parcelable {
@Parcelize
data object SingleMedia : MediaViewerMode
@Parcelize
data class TimelineImagesAndVideos(val timelineMode: Timeline.Mode) : MediaViewerMode
@@ -13,40 +13,20 @@ import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.AvatarInfo
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.impl.viewer.MediaViewerNode
@ContributesBinding(AppScope::class)
class DefaultMediaViewerEntryPoint : MediaViewerEntryPoint {
override fun createParamsForAvatar(filename: String, avatarUrl: String): MediaViewerEntryPoint.Params {
// We need to fake the MimeType here for the viewer to work.
val mimeType = MimeTypes.Images
return MediaViewerEntryPoint.Params(
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
eventId = null,
mediaInfo = MediaInfo(
return MediaViewerEntryPoint.Params.Avatar(
avatarInfo = AvatarInfo(
filename = filename,
fileSize = null,
caption = null,
formattedCaption = null,
mimeType = mimeType,
formattedFileSize = "",
fileExtension = "",
senderId = UserId("@dummy:server.org"),
senderName = null,
senderAvatar = null,
dateSent = null,
dateSentFull = null,
waveform = null,
duration = null,
),
mediaSource = MediaSource(url = avatarUrl),
thumbnailSource = null,
canShowInfo = false,
)
}
@@ -13,6 +13,7 @@ import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.dateformatter.api.toHumanReadableDuration
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent
@@ -20,6 +21,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageT
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryItemType
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent
@@ -54,7 +57,7 @@ class EventItemFactory(
) {
fun create(
currentTimelineItem: MatrixTimelineItem.Event,
): MediaItem.Event? {
): List<MediaItem.Event> {
val event = currentTimelineItem.event
val dateSent = dateFormatter.format(
currentTimelineItem.event.timestamp,
@@ -79,7 +82,7 @@ class EventItemFactory(
is LiveLocationContent,
UnknownContent -> {
Timber.w("Should not happen: ${content.javaClass.simpleName}")
null
emptyList()
}
is MessageContent -> {
when (val type = content.type) {
@@ -89,9 +92,93 @@ class EventItemFactory(
is LocationMessageType,
is TextMessageType -> {
Timber.w("Should not happen: ${content.type}")
null
emptyList()
}
is AudioMessageType -> MediaItem.Audio(
is GalleryMessageType -> {
val baseId = currentTimelineItem.uniqueId.value
type.items.mapIndexedNotNull { index, galleryItem ->
val id = UniqueId("${baseId}_$index")
when (galleryItem) {
is GalleryItemType.Image -> {
val c = galleryItem.content
MediaItem.Image(
id = id,
eventId = currentTimelineItem.eventId,
mediaInfo = createMediaInfo(
filename = c.filename,
fileSize = c.info?.size,
caption = c.caption,
mimeType = c.info?.mimetype.orEmpty(),
fileExtension = c.filename,
event = event,
dateSent = dateSent,
dateSentFull = dateSentFull,
),
mediaSource = c.source,
thumbnailSource = c.info?.thumbnailSource,
)
}
is GalleryItemType.Video -> {
val c = galleryItem.content
MediaItem.Video(
id = id,
eventId = currentTimelineItem.eventId,
mediaInfo = createMediaInfo(
filename = c.filename,
fileSize = c.info?.size,
caption = c.caption,
mimeType = c.info?.mimetype.orEmpty(),
fileExtension = c.filename,
event = event,
dateSent = dateSent,
dateSentFull = dateSentFull,
duration = c.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
),
mediaSource = c.source,
thumbnailSource = c.info?.thumbnailSource,
)
}
is GalleryItemType.Audio -> {
val c = galleryItem.content
MediaItem.Audio(
id = id,
eventId = currentTimelineItem.eventId,
mediaInfo = createMediaInfo(
filename = c.filename,
fileSize = c.info?.size,
caption = c.caption,
mimeType = c.info?.mimetype.orEmpty(),
fileExtension = c.filename,
event = event,
dateSent = dateSent,
dateSentFull = dateSentFull,
),
mediaSource = c.source,
)
}
is GalleryItemType.File -> {
val c = galleryItem.content
MediaItem.File(
id = id,
eventId = currentTimelineItem.eventId,
mediaInfo = createMediaInfo(
filename = c.filename,
fileSize = c.info?.size,
caption = c.caption,
mimeType = c.info?.mimetype.orEmpty(),
fileExtension = c.filename,
event = event,
dateSent = dateSent,
dateSentFull = dateSentFull,
),
mediaSource = c.source,
)
}
is GalleryItemType.Other -> null
}
}
}
is AudioMessageType -> listOf(MediaItem.Audio(
id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId,
mediaInfo = MediaInfo(
@@ -111,8 +198,8 @@ class EventItemFactory(
duration = null,
),
mediaSource = type.source,
)
is FileMessageType -> MediaItem.File(
))
is FileMessageType -> listOf(MediaItem.File(
id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId,
mediaInfo = MediaInfo(
@@ -133,8 +220,8 @@ class EventItemFactory(
),
mediaSource = type.source,
// TODO We may want to add a thumbnailSource and set it to type.info?.thumbnailSource
)
is ImageMessageType -> MediaItem.Image(
))
is ImageMessageType -> listOf(MediaItem.Image(
id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId,
mediaInfo = MediaInfo(
@@ -155,8 +242,8 @@ class EventItemFactory(
),
mediaSource = type.source,
thumbnailSource = type.info?.thumbnailSource,
)
is StickerMessageType -> MediaItem.Image(
))
is StickerMessageType -> listOf(MediaItem.Image(
id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId,
mediaInfo = MediaInfo(
@@ -177,8 +264,8 @@ class EventItemFactory(
),
mediaSource = type.source,
thumbnailSource = type.info?.thumbnailSource,
)
is VideoMessageType -> MediaItem.Video(
))
is VideoMessageType -> listOf(MediaItem.Video(
id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId,
mediaInfo = MediaInfo(
@@ -199,8 +286,8 @@ class EventItemFactory(
),
mediaSource = type.source,
thumbnailSource = type.info?.thumbnailSource,
)
is VoiceMessageType -> MediaItem.Voice(
))
is VoiceMessageType -> listOf(MediaItem.Voice(
id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId,
mediaInfo = MediaInfo(
@@ -220,9 +307,36 @@ class EventItemFactory(
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
),
mediaSource = type.source,
)
))
}
}
}
}
private fun createMediaInfo(
filename: String,
fileSize: Long?,
caption: String?,
mimeType: String,
fileExtension: String,
event: io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem,
dateSent: String,
dateSentFull: String,
waveform: List<Float>? = null,
duration: String? = null,
) = MediaInfo(
filename = filename,
fileSize = fileSize,
caption = caption,
mimeType = mimeType,
formattedFileSize = fileSize?.let { fileSizeFormatter.format(it) }.orEmpty(),
fileExtension = fileExtensionExtractor.extractFromName(fileExtension),
senderId = event.sender,
senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender),
senderAvatar = event.senderProfile.getAvatarUrl(),
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = waveform,
duration = duration,
)
}
@@ -9,10 +9,8 @@
package io.element.android.libraries.mediaviewer.impl.datasource
import dev.zacsweers.metro.Inject
import io.element.android.libraries.androidutils.diff.DefaultDiffCacheInvalidator
import io.element.android.libraries.androidutils.diff.DiffCacheUpdater
import io.element.android.libraries.androidutils.diff.MutableListDiffCache
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import kotlinx.collections.immutable.ImmutableList
@@ -32,18 +30,8 @@ class TimelineMediaItemsFactory(
) {
private val _timelineItems = MutableSharedFlow<ImmutableList<MediaItem>>(replay = 1)
private val lock = Mutex()
private val diffCache = MutableListDiffCache<MediaItem>()
private val diffCacheUpdater = DiffCacheUpdater<MatrixTimelineItem, MediaItem>(
diffCache = diffCache,
detectMoves = false,
cacheInvalidator = DefaultDiffCacheInvalidator()
) { old, new ->
if (old is MatrixTimelineItem.Event && new is MatrixTimelineItem.Event) {
old.uniqueId == new.uniqueId
} else {
false
}
}
private val cache = mutableMapOf<UniqueId, List<MediaItem>>()
private var previousTimelineItems: List<MatrixTimelineItem> = emptyList()
val timelineItems: Flow<ImmutableList<MediaItem>> = _timelineItems.distinctUntilChanged()
@@ -51,39 +39,42 @@ class TimelineMediaItemsFactory(
timelineItems: List<MatrixTimelineItem>,
) = withContext(dispatchers.computation) {
lock.withLock {
diffCacheUpdater.updateWith(timelineItems)
buildAndEmitTimelineItemStates(timelineItems)
}
}
private suspend fun buildAndEmitTimelineItemStates(
timelineItems: List<MatrixTimelineItem>,
) {
val newTimelineItemStates = ArrayList<MediaItem>()
for (index in diffCache.indices().reversed()) {
val cacheItem = diffCache.get(index)
if (cacheItem == null) {
buildAndCacheItem(timelineItems, index)?.also { timelineItemState ->
newTimelineItemStates.add(timelineItemState)
val newTimelineItemStates = ArrayList<MediaItem>()
for (index in timelineItems.indices.reversed()) {
when (val currentTimelineItem = timelineItems[index]) {
is MatrixTimelineItem.Event -> {
val cachedItems = cache[currentTimelineItem.uniqueId]
val items = if (cachedItems != null && isItemUnchanged(currentTimelineItem, previousTimelineItems)) {
cachedItems
} else {
eventItemFactory.create(currentTimelineItem).also { newItems ->
cache[currentTimelineItem.uniqueId] = newItems
}
}
newTimelineItemStates.addAll(items.asReversed())
}
is MatrixTimelineItem.Virtual -> {
virtualItemFactory.create(currentTimelineItem)?.also {
newTimelineItemStates.add(it)
}
}
MatrixTimelineItem.Other -> Unit
}
} else {
newTimelineItemStates.add(cacheItem)
}
previousTimelineItems = timelineItems
_timelineItems.emit(newTimelineItemStates.toImmutableList())
}
_timelineItems.emit(newTimelineItemStates.toImmutableList())
}
private fun buildAndCacheItem(
timelineItems: List<MatrixTimelineItem>,
index: Int,
): MediaItem? {
val timelineItem =
when (val currentTimelineItem = timelineItems[index]) {
is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem)
is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem)
MatrixTimelineItem.Other -> null
}
diffCache[index] = timelineItem
return timelineItem
private fun isItemUnchanged(
currentTimelineItem: MatrixTimelineItem.Event,
previousItems: List<MatrixTimelineItem>,
): Boolean {
if (previousItems.isEmpty()) return false
val previousIndex = previousItems.indexOfFirst { it is MatrixTimelineItem.Event && it.uniqueId == currentTimelineItem.uniqueId }
if (previousIndex < 0) return false
val previousItem = previousItems[previousIndex] as? MatrixTimelineItem.Event ?: return false
return previousItem.event.eventId == currentTimelineItem.event.eventId &&
previousItem.event.timestamp == currentTimelineItem.event.timestamp
}
}
@@ -128,13 +128,12 @@ class MediaGalleryFlowNode(
mediaViewerEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = MediaViewerEntryPoint.Params(
params = MediaViewerEntryPoint.Params.RoomMedia(
mode = navTarget.mode,
eventId = navTarget.eventId,
mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource,
canShowInfo = true,
),
callback = callback,
)
@@ -0,0 +1,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 galleryMode = when (mode) {
MediaViewerMode.SingleMedia,
is MediaViewerMode.TimelineImagesAndVideos -> MediaGalleryMode.Images
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.
*/
fun findEventIndex(eventId: EventId?): Int? {
fun findEventIndex(eventId: EventId?, mediaSource: MediaSource? = null): Int? {
if (eventId == null) return null
return dataFlow.value.indexOfFirst { (it as? MediaViewerPageData.MediaViewerData)?.eventId == eventId }.takeIf { it >= 0 }
return dataFlow.value.indexOfFirst {
val pageData = it as? MediaViewerPageData.MediaViewerData
pageData?.eventId == eventId && (mediaSource == null || pageData.mediaSource == mediaSource)
}.takeIf { it >= 0 }
}
@VisibleForTesting
@@ -74,42 +74,49 @@ class MediaViewerNode(
callback.onDone()
}
private val mediaGallerySource = if (inputs.mode == MediaViewerEntryPoint.MediaViewerMode.SingleMedia) {
SingleMediaGalleryDataSource.createFrom(inputs)
} else {
val eventId = inputs.eventId
if (eventId == null) {
// Should not happen
timelineMediaGalleryDataSource
} else {
// Can we use a specific timeline?
val timelineMode = inputs.mode.getTimelineMode()
when (timelineMode) {
null -> timelineMediaGalleryDataSource
Timeline.Mode.Live,
is Timeline.Mode.FocusedOnEvent,
is Timeline.Mode.Thread -> {
// Does timelineMediaGalleryDataSource knows the eventId?
val lastData = timelineMediaGalleryDataSource.getLastData().dataOrNull()
val isEventKnown = lastData?.hasEvent(eventId) == true
if (isEventKnown) {
timelineMediaGalleryDataSource
} else {
private val mediaGallerySource = when (inputs) {
is MediaViewerEntryPoint.Params.Avatar ->
SingleMediaGalleryDataSource.createFrom(inputs)
is MediaViewerEntryPoint.Params.EventGallery ->
GalleryMediaGalleryDataSource.createFrom(
eventId = inputs.eventId,
galleryItems = inputs.galleryItems,
galleryInfo = inputs.galleryInfo,
)
is MediaViewerEntryPoint.Params.RoomMedia -> {
val eventId = inputs.eventId
if (eventId == null) {
// Should not happen
timelineMediaGalleryDataSource
} else {
// Can we use a specific timeline?
val timelineMode = inputs.mode.getTimelineMode()
when (timelineMode) {
Timeline.Mode.Live,
is Timeline.Mode.FocusedOnEvent,
is Timeline.Mode.Thread -> {
// Does timelineMediaGalleryDataSource knows the eventId?
val lastData = timelineMediaGalleryDataSource.getLastData().dataOrNull()
val isEventKnown = lastData?.hasEvent(eventId) == true
if (isEventKnown) {
timelineMediaGalleryDataSource
} else {
focusedTimelineMediaGalleryDataSourceFactory.createFor(
eventId = eventId,
mediaItem = inputs.toMediaItem(),
onlyPinnedEvents = false,
)
}
}
Timeline.Mode.PinnedEvents -> {
focusedTimelineMediaGalleryDataSourceFactory.createFor(
eventId = eventId,
mediaItem = inputs.toMediaItem(),
onlyPinnedEvents = false,
onlyPinnedEvents = true,
)
}
Timeline.Mode.Media -> timelineMediaGalleryDataSource
}
Timeline.Mode.PinnedEvents -> {
focusedTimelineMediaGalleryDataSourceFactory.createFor(
eventId = eventId,
mediaItem = inputs.toMediaItem(),
onlyPinnedEvents = true,
)
}
Timeline.Mode.Media -> timelineMediaGalleryDataSource
}
}
}
@@ -118,7 +125,18 @@ class MediaViewerNode(
inputs = inputs,
navigator = this,
dataSource = MediaViewerDataSource(
mode = inputs.mode,
mode = when (inputs) {
is MediaViewerEntryPoint.Params.Avatar ->
MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media)
is MediaViewerEntryPoint.Params.EventGallery ->
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,
dispatcher = coroutineDispatchers.computation,
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) {
is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> 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
private val snackbarDispatcher = SnackbarDispatcher()
private val eventId = inputs.eventId()
private val mediaSource = inputs.mediaSource()
@Composable
override fun present(): MediaViewerState {
val coroutineScope = rememberCoroutineScope()
val currentIndex = remember { mutableIntStateOf(dataSource.findEventIndex(inputs.eventId) ?: 0) }
val currentIndex = remember {
val firstIndex = if (inputs is MediaViewerEntryPoint.Params.EventGallery) {
// Order is reversed so we have to reverse the index
inputs.galleryItems.lastIndex - inputs.galleryInfo.initialIndex
} else {
dataSource.findEventIndex(eventId, mediaSource) ?: 0
}
mutableIntStateOf(firstIndex)
}
val data = dataSource.produceState { flow ->
flow.collectLatest { new ->
val existingItem = value.getOrNull(currentIndex.intValue)
val newItem = new.getOrNull(currentIndex.intValue)
if (existingItem is MediaViewerPageData.MediaViewerData && existingItem.eventId == inputs.eventId && newItem != existingItem) {
currentIndex.intValue = dataSource.findEventIndex(inputs.eventId) ?: 0
if (existingItem is MediaViewerPageData.MediaViewerData && existingItem.eventId == eventId && newItem != existingItem) {
currentIndex.intValue = dataSource.findEventIndex(eventId, mediaSource) ?: 0
} else if (currentIndex.intValue > 0 && value.firstOrNull() is MediaViewerPageData.Loading &&
new.firstOrNull() !is MediaViewerPageData.Loading) {
// Restore index based on the eventId after the initial items have been loaded
currentIndex.intValue = dataSource.findEventIndex(inputs.eventId) ?: 0
currentIndex.intValue = dataSource.findEventIndex(eventId, mediaSource) ?: 0
}
value = new
}
@@ -140,7 +151,8 @@ class MediaViewerPresenter(
mediaBottomSheetState = MediaBottomSheetState.Hidden
navigator.onForwardClick(
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 {
@@ -176,11 +188,11 @@ class MediaViewerPresenter(
}
return MediaViewerState(
initiallySelectedEventId = inputs.eventId,
initiallySelectedEventId = eventId,
listData = data.value,
currentIndex = currentIndex.intValue,
snackbarMessage = snackbarMessage,
canShowInfo = inputs.canShowInfo,
canShowInfo = inputs !is MediaViewerEntryPoint.Params.Avatar,
mediaBottomSheetState = mediaBottomSheetState,
eventSink = ::handleEvent,
)
@@ -224,13 +236,14 @@ class MediaViewerPresenter(
}
private fun showNoMoreItemsSnackbar() {
val messageResId = when (inputs.mode) {
MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> R.string.screen_media_details_no_more_media_to_show
is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> R.string.screen_media_details_no_more_files_to_show
if (inputs is MediaViewerEntryPoint.Params.RoomMedia) {
val messageResId = when (inputs.mode) {
is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> R.string.screen_media_details_no_more_media_to_show
is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> R.string.screen_media_details_no_more_files_to_show
}
val message = SnackbarMessage(messageResId)
snackbarDispatcher.post(message)
}
val message = SnackbarMessage(messageResId)
snackbarDispatcher.post(message)
}
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 io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.eventId
import io.element.android.libraries.mediaviewer.impl.model.mediaSource
/**
* x and y are loading items.
@@ -50,31 +51,32 @@ class PagerKeysHandler {
if (cachedData.mediaItems.isEmpty()) {
cachedData = Data(mediaItems, 0)
} else {
// Search a common item in both lists, i.e. an item with the same eventId
val itemInCacheIndex = cachedData.mediaItems.indexOfFirst { mediaItem ->
mediaItem is MediaItem.Event && mediaItems
// Search a common item in both lists using eventId + mediaSource to handle gallery items
val itemInCacheIndex = cachedData.mediaItems.indexOfFirst { cachedItem ->
cachedItem is MediaItem.Event && mediaItems
.filterIsInstance<MediaItem.Event>()
.any { mediaItem.eventId() == it.eventId() }
.any { newItem ->
cachedItem.eventId() == newItem.eventId() &&
cachedItem.mediaSource().safeUrl == newItem.mediaSource().safeUrl
}
}
cachedData = if (itemInCacheIndex == -1) {
// If the item is not found, start with a new cache
Data(mediaItems, 0)
} else {
val cachedItem = cachedData.mediaItems[itemInCacheIndex]
val eventId = (cachedItem as? MediaItem.Event)?.eventId()
if (eventId == null) {
// Should not happen, but in this case, start with a new cache
val cachedSourceUrl = (cachedItem as? MediaItem.Event)?.mediaSource()?.safeUrl
if (eventId == null || cachedSourceUrl == null) {
Data(mediaItems, 0)
} else {
// Search the index of the item in the new list
val itemIndex = mediaItems.indexOfFirst { mediaItem ->
mediaItem is MediaItem.Event && mediaItem.eventId() == eventId
mediaItem is MediaItem.Event &&
mediaItem.eventId() == eventId &&
mediaItem.mediaSource().safeUrl == cachedSourceUrl
}
if (itemIndex == -1) {
// If the item is not found, start with a new cache
Data(mediaItems, 0)
} else {
// Update the cache with the new list and the new offset
Data(mediaItems, cachedData.keyOffset + itemInCacheIndex - itemIndex.toLong())
}
}
@@ -9,12 +9,14 @@
package io.element.android.libraries.mediaviewer.impl.viewer
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
@@ -36,17 +38,40 @@ class SingleMediaGalleryDataSource(
override suspend fun deleteItem(eventId: EventId) = Unit
companion object {
fun createFrom(params: MediaViewerEntryPoint.Params) = SingleMediaGalleryDataSource(
fun createFrom(params: MediaViewerEntryPoint.Params.Avatar) = SingleMediaGalleryDataSource(
data = GroupedMediaItems(
// Always use imageAndVideoItems, in Single mode, this is the data that will be used
imageAndVideoItems = persistentListOf(params.toMediaItem()),
imageAndVideoItems = persistentListOf(
MediaItem.Image(
id = UniqueId("dummy"),
eventId = null,
mediaInfo = MediaInfo(
filename = params.avatarInfo.filename,
caption = null,
formattedCaption = null,
mimeType = MimeTypes.Images,
fileSize = null,
formattedFileSize = "",
fileExtension = "",
senderId = null,
senderName = null,
senderAvatar = null,
dateSent = null,
dateSentFull = null,
waveform = null,
duration = null,
),
mediaSource = params.mediaSource,
thumbnailSource = params.thumbnailSource,
)
),
fileItems = persistentListOf(),
)
)
}
}
fun MediaViewerEntryPoint.Params.toMediaItem() = when {
fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
mediaInfo.mimeType.isMimeTypeImage() -> {
MediaItem.Image(
id = UniqueId("dummy"),
@@ -13,14 +13,12 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
import io.element.android.libraries.mediaplayer.test.FakeAudioFocus
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.AvatarInfo
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.impl.datasource.createTimelineMediaGalleryDataSource
import io.element.android.libraries.mediaviewer.impl.viewer.MediaViewerNode
@@ -128,7 +126,7 @@ class DefaultMediaViewerEntryPointTest {
override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError()
}
val params = entryPoint.createParamsForAvatar(
filename = "fn",
filename = "avatar.png",
avatarUrl = "avatarUrl",
)
val result = entryPoint.createNode(
@@ -139,27 +137,10 @@ class DefaultMediaViewerEntryPointTest {
)
assertThat(result).isInstanceOf(MediaViewerNode::class.java)
assertThat(result.plugins).contains(
MediaViewerEntryPoint.Params(
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
eventId = null,
mediaInfo = MediaInfo(
filename = "fn",
fileSize = null,
caption = null,
mimeType = MimeTypes.Images,
formattedFileSize = "",
fileExtension = "",
senderId = UserId("@dummy:server.org"),
senderName = null,
senderAvatar = null,
dateSent = null,
dateSentFull = null,
waveform = null,
duration = null,
),
MediaViewerEntryPoint.Params.Avatar(
avatarInfo = AvatarInfo(filename = "avatar.png"),
mediaSource = MediaSource(url = "avatarUrl"),
thumbnailSource = null,
canShowInfo = false,
)
)
assertThat(result.plugins).contains(callback)
@@ -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(
id = A_UNIQUE_ID,
eventId = AN_EVENT_ID,
@@ -201,7 +201,7 @@ class DefaultEventItemFactoryTest {
)
)
)
assertThat(result).isEqualTo(
assertThat(result).containsExactly(
MediaItem.Image(
id = A_UNIQUE_ID,
eventId = AN_EVENT_ID,
@@ -249,7 +249,7 @@ class DefaultEventItemFactoryTest {
)
)
)
assertThat(result).isEqualTo(
assertThat(result).containsExactly(
MediaItem.Audio(
id = A_UNIQUE_ID,
eventId = AN_EVENT_ID,
@@ -301,7 +301,7 @@ class DefaultEventItemFactoryTest {
)
)
)
assertThat(result).isEqualTo(
assertThat(result).containsExactly(
MediaItem.Video(
id = A_UNIQUE_ID,
eventId = AN_EVENT_ID,
@@ -353,7 +353,7 @@ class DefaultEventItemFactoryTest {
)
)
)
assertThat(result).isEqualTo(
assertThat(result).containsExactly(
MediaItem.Voice(
id = A_UNIQUE_ID,
eventId = AN_EVENT_ID,
@@ -404,7 +404,7 @@ class DefaultEventItemFactoryTest {
)
)
)
assertThat(result).isEqualTo(
assertThat(result).containsExactly(
MediaItem.Image(
id = A_UNIQUE_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.powerlevels.FakeRoomPermissions
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.mediaviewer.api.AvatarInfo
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
@@ -109,7 +110,11 @@ class MediaViewerPresenterTest {
fun `present - initial state cannot show info`() = runTest {
val presenter = createMediaViewerPresenter(
localMediaFactory = localMediaFactory,
canShowInfo = false,
inputs = MediaViewerEntryPoint.Params.Avatar(
avatarInfo = AvatarInfo(filename = "avatar.png"),
mediaSource = aMediaSource(),
thumbnailSource = null,
),
room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = FakeRoomPermissions(
@@ -639,7 +644,7 @@ class MediaViewerPresenterTest {
}
)
)
skipItems(2)
skipItems(1)
val stateWithSnackbar = awaitItem()
assertThat(stateWithSnackbar.snackbarMessage!!.messageResId).isEqualTo(expectedSnackbarResId)
}
@@ -932,29 +937,31 @@ class MediaViewerPresenterTest {
internal fun TestScope.createMediaViewerPresenter(
localMediaFactory: LocalMediaFactory,
inputs: MediaViewerEntryPoint.Params? = null,
eventId: EventId? = null,
mode: MediaViewerEntryPoint.MediaViewerMode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
mode: MediaViewerEntryPoint.MediaViewerMode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media),
matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(),
localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(),
mediaGalleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
),
canShowInfo: Boolean = true,
mediaViewerNavigator: MediaViewerNavigator = FakeMediaViewerNavigator(),
room: JoinedRoom = FakeJoinedRoom(
liveTimeline = FakeTimeline(),
),
): MediaViewerPresenter {
val actualInputs = inputs ?: createMediaViewerEntryPointParams(eventId = eventId, mode = mode)
val actualMode = when (actualInputs) {
is MediaViewerEntryPoint.Params.Avatar -> MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media)
is MediaViewerEntryPoint.Params.EventGallery -> MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media)
is MediaViewerEntryPoint.Params.RoomMedia -> actualInputs.mode
}
return MediaViewerPresenter(
inputs = createMediaViewerEntryPointParams(
eventId = eventId,
mode = mode,
canShowInfo = canShowInfo,
),
inputs = actualInputs,
navigator = mediaViewerNavigator,
dataSource = MediaViewerDataSource(
coroutineScope = backgroundScope,
mode = mode,
mode = actualMode,
dispatcher = testCoroutineDispatchers().computation,
galleryDataSource = mediaGalleryDataSource,
mediaLoader = matrixMediaLoader,
@@ -969,13 +976,11 @@ internal fun TestScope.createMediaViewerPresenter(
internal fun createMediaViewerEntryPointParams(
eventId: EventId? = null,
mode: MediaViewerEntryPoint.MediaViewerMode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
canShowInfo: Boolean = true,
) = MediaViewerEntryPoint.Params(
mode: MediaViewerEntryPoint.MediaViewerMode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode = Timeline.Mode.Media),
) = MediaViewerEntryPoint.Params.RoomMedia(
mode = mode,
eventId = eventId,
mediaInfo = TESTED_MEDIA_INFO,
mediaSource = aMediaSource(),
thumbnailSource = null,
canShowInfo = canShowInfo,
)
@@ -160,23 +160,20 @@ class SingleMediaGalleryDataSourceTest {
private fun testFactory(
mediaInfo: MediaInfo,
expectedResult: (MediaViewerEntryPoint.Params) -> MediaItem,
expectedResult: (MediaViewerEntryPoint.Params.RoomMedia) -> MediaItem,
) {
val params = aMediaViewerEntryPointParams(mediaInfo)
val result = SingleMediaGalleryDataSource.createFrom(params)
val resultData = result.getLastData().dataOrNull()
assertThat(resultData!!.imageAndVideoItems.first()).isEqualTo(expectedResult(params))
assertThat(resultData.fileItems).isEmpty()
val result = params.toMediaItem()
assertThat(result).isEqualTo(expectedResult(params))
}
internal fun aMediaViewerEntryPointParams(
mediaInfo: MediaInfo,
) = MediaViewerEntryPoint.Params(
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
) = MediaViewerEntryPoint.Params.RoomMedia(
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media),
eventId = AN_EVENT_ID,
mediaInfo = mediaInfo,
mediaSource = aMediaSource(url = "aUrl"),
thumbnailSource = aMediaSource(url = "aThumbnailUrl"),
canShowInfo = true,
)
}
@@ -41,6 +41,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageT
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
@@ -344,6 +345,7 @@ class DefaultNotifiableEventResolver(
is TextMessageType -> messageType.toPlainText(permalinkParser = permalinkParser)
is VideoMessageType -> messageType.bestDescription
is LocationMessageType -> messageType.body
is GalleryMessageType -> messageType.body
is OtherMessageType -> messageType.body
}
}
@@ -5,6 +5,8 @@
<string name="a11y_address">"Address"</string>
<string name="a11y_avatar">"Avatar"</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>
<plurals name="a11y_digits_entered">
<item quantity="one">"%1$d digit entered"</item>
@@ -194,9 +196,11 @@
<string name="action_translate">"Translate"</string>
<string name="action_try_again">"Try again"</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_in_timeline">"View in timeline"</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_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>
@@ -261,6 +265,7 @@ Reason: %1$s."</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_frequently_used">"Frequently used"</string>
<string name="common_gallery">"Gallery"</string>
<string name="common_gif">"GIF"</string>
<string name="common_group_call_in_progress">"Group call in progress"</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_remove_accessibility_label">"Remove %1$s"</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_sharing_live_location">"Sharing live location"</string>
<plurals name="screen_live_location_sheet_subtitle">
@@ -140,7 +140,10 @@ class KonsistClassNameTest {
Konsist.scopeFromProject()
.classes()
.withNameEndingWith("Impl")
.withoutName("MediaUploadHandlerImpl")
.withoutName(
"MediaUploadHandlerImpl",
"GalleryMediaUploadHandlerImpl",
)
.assertEmpty(additionalMessage = "Class implementing interface should have name not end with 'Impl' but start with 'Default'")
}
@@ -149,6 +149,7 @@ class KonsistPreviewTest {
"TimelineItemEventRowShieldPreview",
"TimelineItemEventRowTimestampPreview",
"TimelineItemEventRowUtdPreview",
"TimelineItemEventRowWithGalleryPreview",
"TimelineItemEventRowWithManyReactionsPreview",
"TimelineItemEventRowWithRRPreview",
"TimelineItemEventRowWithReplyPreview",
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1885bbe8b0ea497ac20d7bcdd0c6a93ba0533e898378d35e1cafdc8c9924fd56
size 279984
oid sha256:de82970da241172b94f822f8792e6fc721dcfc7f0f67e3523962e253e9750884
size 255933
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ed2c757e43dd8b25eb10acf32223168d10231ab2c9a9019ffe6b4eec6d656087
size 343464
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3dbb8ef39b6a7a0c2f576d8bd101aa97c89da696f23e6d5836846494471c01a1
size 55799
oid sha256:3274314ab9e39c23a6b9d66adf80ac8bb7b364ce60a300f96582374a18bc821f
size 54590
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:085963fcb425147d35a9ad947a7a3e4ba4712bd3d2f32e93099ff15cffe83b88
size 64761
oid sha256:65f9ee083a73669a0d3f9d88141a610ea53f3c39e89d8f706d414b04555b4764
size 59321
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:28512b0fda0b71b98eab03cee15c9d3163825b1a3545670cfa277ddd5434c87a
size 58417
oid sha256:6f7f7415abbe51858f938e16ad03c6aab983fb3d5e2a53a028bae3c031446646
size 67848
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81
size 3657
oid sha256:87193a7c9eb9d40234970e5f5be02e80f2b3ffe91e2c9ca6afd58b8bccdd5911
size 55075

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