From 5f508bdf527cf3f26c9e6ab2e1e9da050f0b57f9 Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:29:10 +0000 Subject: [PATCH 01/52] Support gallery messages --- .../messages/impl/MessagesFlowNode.kt | 82 ++- .../features/messages/impl/MessagesNode.kt | 14 +- .../features/messages/impl/MessagesView.kt | 12 + .../impl/actionlist/ActionListView.kt | 8 + .../preview/AttachmentsPreviewEvent.kt | 1 + .../preview/AttachmentsPreviewNode.kt | 5 +- .../preview/AttachmentsPreviewPresenter.kt | 281 ++++---- .../preview/AttachmentsPreviewState.kt | 13 +- .../AttachmentsPreviewStateProvider.kt | 40 +- .../preview/AttachmentsPreviewView.kt | 164 ++++- .../MessageComposerPresenter.kt | 32 +- .../impl/threads/ThreadedMessagesNode.kt | 16 +- .../messages/impl/timeline/TimelineView.kt | 2 + .../components/TimelineItemEventRow.kt | 261 ++++++- .../timeline/components/TimelineItemRow.kt | 5 +- .../event/TimelineItemAttachmentsListView.kt | 305 ++++++++ .../event/TimelineItemEventContentView.kt | 18 + .../event/TimelineItemGalleryView.kt | 650 ++++++++++++++++++ .../components/event/TimelineItemVideoView.kt | 89 ++- .../TimelineItemContentMessageFactory.kt | 151 ++++ .../impl/timeline/groups/Groupability.kt | 4 + .../impl/timeline/model/TimelineItem.kt | 4 + .../event/TimelineItemAttachmentsContent.kt | 35 + .../model/event/TimelineItemEventContent.kt | 8 +- .../event/TimelineItemEventContentProvider.kt | 80 +++ .../model/event/TimelineItemGalleryContent.kt | 59 ++ .../impl/timeline/protection/TimelineItem.kt | 4 + .../DefaultMessageSummaryFormatter.kt | 12 + .../impl/src/main/res/values/temporary.xml | 9 + .../AttachmentsPreviewPresenterTest.kt | 26 +- .../impl/attachments/SendActionStateTest.kt | 2 +- .../DefaultPinnedMessagesBannerFormatter.kt | 4 + .../impl/DefaultRoomLatestEventFormatter.kt | 4 + .../matrix/api/media/GalleryItemInfo.kt | 48 ++ .../libraries/matrix/api/timeline/Timeline.kt | 8 + .../api/timeline/item/event/MessageType.kt | 15 + .../matrix/impl/media/FormattedBody.kt | 25 + .../impl/media/GalleryItemInfoMapper.kt | 50 ++ .../media/GalleryMediaUploadHandlerImpl.kt | 35 + ...imelineEventToNotificationContentMapper.kt | 5 +- .../matrix/impl/room/JoinedRustRoom.kt | 1 + .../matrix/impl/timeline/RustTimeline.kt | 39 ++ .../timeline/item/event/EventMessageMapper.kt | 66 +- .../matrix/test/timeline/FakeTimeline.kt | 24 + .../mediapickers/api/PickerProvider.kt | 11 + .../libraries/mediapickers/api/PickerType.kt | 19 + .../impl/DefaultPickerProvider.kt | 37 +- .../mediapickers/test/FakePickerProvider.kt | 10 + .../libraries/mediaupload/api/MediaSender.kt | 7 + .../mediaupload/api/MediaUploadInfo.kt | 38 + .../mediaupload/impl/DefaultMediaSender.kt | 24 + .../mediaupload/test/FakeMediaSender.kt | 10 + .../mediaviewer/api/GalleryItemData.kt | 24 + .../mediaviewer/api/MediaViewerEntryPoint.kt | 1 + .../impl/datasource/EventItemFactory.kt | 144 +++- .../datasource/TimelineMediaItemsFactory.kt | 80 +-- .../viewer/GalleryMediaGalleryDataSource.kt | 104 +++ .../impl/viewer/MediaViewerNode.kt | 7 + .../impl/viewer/MediaViewerPresenter.kt | 17 + .../impl/viewer/PagerKeysHandler.kt | 24 +- .../datasource/DefaultEventItemFactoryTest.kt | 16 +- .../DefaultNotifiableEventResolver.kt | 2 + 62 files changed, 2983 insertions(+), 308 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAttachmentsContent.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt create mode 100644 features/messages/impl/src/main/res/values/temporary.xml create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/GalleryItemInfo.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/FormattedBody.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryItemInfoMapper.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryMediaUploadHandlerImpl.kt create mode 100644 libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/GalleryItemData.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSource.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index d20dc4b38e8..497b33c8c03 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -46,6 +46,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem 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 @@ -81,6 +82,7 @@ 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.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 @@ -144,10 +146,11 @@ class MessagesFlowNode( val mediaSource: MediaSource, val thumbnailSource: MediaSource?, val canUseOverlay: Boolean, + val galleryItems: List = emptyList(), ) : NavTarget @Parcelize - data class AttachmentPreview(val timelineMode: Timeline.Mode, val attachment: Attachment, val inReplyToEventId: EventId?) : NavTarget + data class AttachmentPreview(val timelineMode: Timeline.Mode, val attachments: ImmutableList, val inReplyToEventId: EventId?) : NavTarget @Parcelize data class LocationViewer(val mode: ShowLocationMode) : NavTarget @@ -228,18 +231,24 @@ 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, + galleryItemIndex: Int?, + ): Boolean { return processEventClick( timelineMode = timelineMode, event = event, canUseOverlay = canUseOverlay, + galleryItemIndex = galleryItemIndex, ) } override fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) { backstack.push( NavTarget.AttachmentPreview( - attachment = attachments.first(), + attachments = attachments, timelineMode = Timeline.Mode.Live, inReplyToEventId = inReplyToEventId, ) @@ -323,6 +332,7 @@ class MessagesFlowNode( mediaSource = navTarget.mediaSource, thumbnailSource = navTarget.thumbnailSource, canShowInfo = true, + galleryItems = navTarget.galleryItems, ) val callback = object : MediaViewerEntryPoint.Callback { override fun onDone() { @@ -351,7 +361,7 @@ class MessagesFlowNode( } is NavTarget.AttachmentPreview -> { val inputs = AttachmentsPreviewNode.Inputs( - attachment = navTarget.attachment, + attachments = navTarget.attachments, timelineMode = navTarget.timelineMode, inReplyToEventId = navTarget.inReplyToEventId, ) @@ -467,18 +477,24 @@ 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, + galleryItemIndex: Int?, + ): Boolean { return processEventClick( timelineMode = timelineMode, event = event, canUseOverlay = canUseOverlay, + galleryItemIndex = galleryItemIndex, ) } override fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) { backstack.push( NavTarget.AttachmentPreview( - attachment = attachments.first(), + attachments = attachments, timelineMode = Timeline.Mode.Thread(navTarget.threadRootId), inReplyToEventId = inReplyToEventId, ) @@ -564,6 +580,7 @@ class MessagesFlowNode( timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean, + galleryItemIndex: Int? = null, ): Boolean { val navTarget = when (event.content) { is TimelineItemImageContent -> { @@ -620,6 +637,59 @@ class MessagesFlowNode( } NavTarget.LocationViewer(mode = mode).takeIf { locationService.isServiceAvailable() } } + is TimelineItemGalleryContent -> { + val item = if (galleryItemIndex != null) { + event.content.items.getOrNull(galleryItemIndex) + } else { + event.content.items.firstOrNull() + } ?: return false + val mediaInfo = MediaInfo( + filename = item.filename, + fileSize = null, + caption = event.content.caption, + mimeType = item.mimeType, + formattedFileSize = "", + fileExtension = item.filename.substringAfterLast('.', ""), + 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, + ), + waveform = null, + duration = null, + ) + val galleryItems = event.content.items.map { galleryItem -> + GalleryItemData( + filename = galleryItem.filename, + mimeType = galleryItem.mimeType, + mediaSource = galleryItem.mediaSource, + thumbnailSource = galleryItem.thumbnailSource, + isVideo = galleryItem.isVideo, + isAudio = galleryItem.isAudio, + isFile = galleryItem.isFile, + ) + }.reversed() + val mode = if (event.content.items.any { it.isVideo || (!it.isAudio && !it.isFile) }) { + MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(timelineMode) + } else { + MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode) + } + NavTarget.MediaViewer( + mode = mode, + eventId = event.eventId, + mediaInfo = mediaInfo, + mediaSource = item.mediaSource, + thumbnailSource = item.thumbnailSource, + canUseOverlay = canUseOverlay, + galleryItems = galleryItems, + ) + } else -> null } return when (navTarget) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index a9ce2f5ba18..44bd8343282 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -117,7 +117,7 @@ class MessagesNode( ) interface Callback : Plugin { - fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean + fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean, galleryItemIndex: Int? = null): Boolean fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) fun navigateToRoomMemberDetails(userId: UserId) fun handlePermalinkClick(data: PermalinkData) @@ -286,6 +286,18 @@ class MessagesNode( } } }, + onGalleryItemClick = { isLive, event, index -> + if (isLive) { + callback.handleEventClick(timelineController.mainTimelineMode(), event, canUseOverlay, index) + } else { + val detachedTimelineMode = timelineController.detachedTimelineMode() + if (detachedTimelineMode != null) { + callback.handleEventClick(detachedTimelineMode, event, canUseOverlay, index) + } else { + false + } + } + }, onUserDataClick = callback::navigateToRoomMemberDetails, onLinkClick = { url, customTab -> onLinkClick( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 3e6f14e8058..594acfa7227 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -135,6 +135,7 @@ fun MessagesView( onBackClick: () -> Unit, onRoomDetailsClick: () -> Unit, onEventContentClick: (isLive: Boolean, event: TimelineItem.Event) -> Boolean, + onGalleryItemClick: ((isLive: Boolean, event: TimelineItem.Event, index: Int) -> Boolean)? = null, onUserDataClick: (UserId) -> Unit, onLinkClick: (String, Boolean) -> Unit, onSendLocationClick: () -> Unit, @@ -257,6 +258,15 @@ fun MessagesView( MessagesViewContent( state = state, onContentClick = ::onContentClick, + onGalleryItemClick = { evt, idx -> + Timber.v("onGalleryItemClick= ${evt.id} index=$idx") + val isLive = state.timelineState.isLive + val handledByGallery = onGalleryItemClick?.invoke(isLive, evt, idx) + val hideKeyboard = handledByGallery ?: onEventContentClick(isLive, evt) + if (hideKeyboard) { + localView.hideKeyboard() + } + }, onMessageLongClick = ::onMessageLongClick, onUserDataClick = { hidingKeyboard { @@ -451,6 +461,7 @@ private fun ReinviteDialog(state: MessagesState) { private fun MessagesViewContent( state: MessagesState, onContentClick: (TimelineItem.Event) -> Unit, + onGalleryItemClick: ((TimelineItem.Event, Int) -> Unit)? = null, onUserDataClick: (MatrixUser) -> Unit, onLinkClick: (Link, Boolean) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit, @@ -509,6 +520,7 @@ private fun MessagesViewContent( onUserDataClick = onUserDataClick, onLinkClick = { link -> onLinkClick(link, false) }, onContentClick = onContentClick, + onGalleryItemClick = onGalleryItemClick, onMessageLongClick = onMessageLongClick, onSwipeToReply = onSwipeToReply, onReactionClick = onReactionClick, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt index e10dd2e1bc7..1f03eb02f5b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt @@ -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) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvent.kt index a7c64845d1c..8f6e4e22394 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvent.kt @@ -21,4 +21,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 } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewNode.kt index 451398d7d34..c3b8d7780f5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewNode.kt @@ -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, 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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt index cab00d99c1a..7298e5892e9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt @@ -26,7 +26,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 @@ -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, @Assisted private val onDoneListener: OnDoneListener, @Assisted private val timelineMode: Timeline.Mode, @Assisted private val inReplyToEventId: EventId?, @@ -76,7 +76,7 @@ class AttachmentsPreviewPresenter( @AssistedFactory interface Factory { fun create( - attachment: Attachment, + attachments: ImmutableList, timelineMode: Timeline.Mode, onDoneListener: OnDoneListener, inReplyToEventId: EventId?, @@ -84,6 +84,7 @@ class AttachmentsPreviewPresenter( } private val mediaSender = mediaSenderFactory.create(timelineMode) + private val isGallery = attachments.size > 1 @Composable override fun present(): AttachmentsPreviewState { @@ -92,8 +93,8 @@ class AttachmentsPreviewPresenter( val sendActionState = remember { mutableStateOf(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(null) } var appliedImageEdits by remember { mutableStateOf(AttachmentImageEdits()) } @@ -108,50 +109,46 @@ class AttachmentsPreviewPresenter( val ongoingSendAttachmentJob = remember { mutableStateOf(null) } + var currentIndex by remember { mutableStateOf(0) } + + var currentAttachments by remember { mutableStateOf(attachments) } + var preprocessMediaJob by remember { mutableStateOf(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()) val observableSendState = snapshotFlow { sendActionState.value } - 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") +var displayFileTooLargeError by remember { mutableStateOf(false) } + + LaunchedEffect(attachments) { + currentIndex = 0 + } + + 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 = coroutineScope.launch(dispatchers.io) { + preProcessAttachments( + attachments = currentAttachments, + mediaOptimizationConfig = config, + displayProgress = false, + sendActionState = sendActionState, + ) + } } } @@ -161,12 +158,11 @@ class AttachmentsPreviewPresenter( 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() + val isImageFile = firstMediaAttachment.localMedia.info.isImageAttachment() + val isVideoFile = firstMediaAttachment.localMedia.info.mimeType.isMimeTypeVideo() if (maxUploadSize != null && !(isImageFile || isVideoFile)) { // If file size is not known, we're permissive and allow sending. The SDK will cancel the upload if needed. - val fileSize = mediaAttachment.localMedia.info.fileSize ?: 0L + val fileSize = firstMediaAttachment.localMedia.info.fileSize ?: 0L if (maxUploadSize < fileSize) { displayFileTooLargeError = true } @@ -192,12 +188,14 @@ 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 = currentAttachments, + mediaOptimizationConfig = config, + displayProgress = true, + sendActionState = sendActionState, + ) + } } // If the processing was hidden before, make it visible now @@ -206,7 +204,7 @@ class AttachmentsPreviewPresenter( } // Wait until the media is ready to be uploaded - val mediaUploadInfo = observableSendState.firstInstanceOf().mediaInfo + val allMediaUploadInfos = observableSendState.firstInstanceOf().mediaInfos // Pre-processing is done, send the attachment val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder) @@ -215,26 +213,18 @@ class AttachmentsPreviewPresenter( 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() - } - // 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() + editedTempFileToDelete?.safeDelete() } } } @@ -251,7 +241,11 @@ class AttachmentsPreviewPresenter( ongoingSendAttachmentJob.value?.cancel() // Dismiss the screen - dismiss(sendActionState, editedTempFile) + dismiss( + attachments = currentAttachments, + sendActionState = sendActionState, + editedTempFile = editedTempFile, + ) } AttachmentsPreviewEvent.CancelAndClearSendState -> { // Cancel media sending @@ -262,7 +256,7 @@ class AttachmentsPreviewPresenter( val mediaUploadInfo = sendActionState.value.mediaUploadInfo() sendActionState.value = if (mediaUploadInfo != null) { - SendActionState.Sending.ReadyToUpload(mediaUploadInfo) + SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo)) } else { SendActionState.Idle } @@ -307,6 +301,9 @@ class AttachmentsPreviewPresenter( editedTempFile = null appliedImageEdits = pendingState.edits currentAttachment = Attachment.Media(originalLocalMedia) + currentAttachments = currentAttachments.toMutableList().also { + it[currentIndex] = currentAttachment + }.toImmutableList() imageEditorState = null resetPreparedMedia(sendActionState) return @@ -326,6 +323,9 @@ class AttachmentsPreviewPresenter( editedTempFile = editedMedia.file appliedImageEdits = pendingState.edits currentAttachment = Attachment.Media(editedMedia.localMedia) + currentAttachments = currentAttachments.toMutableList().also { + it[currentIndex] = currentAttachment + }.toImmutableList() imageEditorState = null resetPreparedMedia(sendActionState) }, @@ -340,11 +340,14 @@ class AttachmentsPreviewPresenter( AttachmentsPreviewEvent.ClearImageEditError -> { displayImageEditError = false } + is AttachmentsPreviewEvent.SetCurrentCarouselIndex -> { + currentIndex = event.index + } } } return AttachmentsPreviewState( - attachment = currentAttachment, + attachments = currentAttachments, imageEditorState = imageEditorState, canEditImage = canEditImage, isApplyingImageEdits = isApplyingImageEdits, @@ -353,92 +356,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, - ) = 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, mediaOptimizationConfig: MediaOptimizationConfig, displayProgress: Boolean, sendActionState: MutableState, ) { 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) + val mediaUploadInfos = mutableListOf() + 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, null) + return + } + } + ) } } - ) + } + sendActionState.value = SendActionState.Sending.ReadyToUpload(mediaUploadInfos) } private fun dismiss( + attachments: List, sendActionState: MutableState, - editedTempFile: File?, + editedTempFile: File? = null, ) { - // Delete the temporary file - when (attachment) { - is Attachment.Media -> { - temporaryUriDeleter.delete(attachment.localMedia.uri) - sendActionState.value.mediaUploadInfo()?.let { data -> - cleanUp(data) + 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) } editedTempFile?.safeDelete() - // Reset the sendActionState to ensure that dialog is closed before the screen sendActionState.value = SendActionState.Done onDoneListener() } @@ -457,36 +430,40 @@ class AttachmentsPreviewPresenter( sendActionState.value = SendActionState.Idle } - private suspend fun sendPreProcessedMedia( - mediaUploadInfo: MediaUploadInfo, + private suspend fun sendGalleryPreProcessed( + mediaUploadInfos: List, caption: String?, sendActionState: MutableState, - 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.first()) + 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.firstOrNull()) } } ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt index 463479fe550..faded71f44d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt @@ -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, 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,7 +40,7 @@ sealed interface SendActionState { @Immutable sealed interface Sending : SendActionState { data class Processing(val displayProgress: Boolean) : Sending - data class ReadyToUpload(val mediaInfo: MediaUploadInfo) : Sending + data class ReadyToUpload(val mediaInfos: List) : Sending data class Uploading(val mediaUploadInfo: MediaUploadInfo) : Sending } @@ -43,7 +48,7 @@ sealed interface SendActionState { data object Done : SendActionState fun mediaUploadInfo(): MediaUploadInfo? = when (this) { - is Sending.ReadyToUpload -> mediaInfo + is Sending.ReadyToUpload -> mediaInfos.firstOrNull() is Sending.Uploading -> mediaUploadInfo is Failure -> mediaUploadInfo else -> null diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt index a2df440a12d..6c416a2c973 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt @@ -41,7 +41,7 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider { - 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(2000L) + 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) { @@ -477,16 +565,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, ) } @@ -529,3 +617,47 @@ fun VideoCompressionPreset.subtitle(): String { } ) } + +@Composable +private fun GalleryBadge(count: Int, modifier: Modifier = Modifier) { + val contentDescription = pluralStringResource(R.plurals.common_attachments_count, count, count) + Box( + modifier = modifier + .padding(12.dp) + .background( + color = ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.8f), + shape = RoundedCornerShape(8.dp), + ) + .padding(horizontal = 8.dp, vertical = 4.dp) + .semantics { + this.contentDescription = contentDescription + }, + ) { + Text( + text = "$count", + style = ElementTheme.typography.fontBodySmMedium, + color = ElementTheme.colors.textPrimary, + ) + } +} + +@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_count, currentIndex, totalCount), + style = ElementTheme.typography.fontBodyMdMedium, + color = ElementTheme.colors.textPrimary, + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index e226318345a..4b7f17dad24 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -178,8 +178,11 @@ class MessageComposerPresenter( val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType -> handlePickedMedia(uri, mimeType) } - val filesPicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri, mimeType -> - handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream, sendAsFile = true) + val galleryMultiMediaPicker = mediaPickerProvider.registerGalleryMultiPicker { uris -> + handlePickedMediaList(uris) + } + val filesPicker = mediaPickerProvider.registerFileMultiPicker(AnyMimeTypes) { uris -> + handlePickedMediaList(uris) } val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri -> handlePickedMedia(uri, MimeTypes.Jpeg) @@ -288,7 +291,7 @@ class MessageComposerPresenter( MessageComposerEvent.DismissAttachmentMenu -> showAttachmentSourcePicker = false MessageComposerEvent.PickAttachmentSource.FromGallery -> localCoroutineScope.launch { showAttachmentSourcePicker = false - galleryMediaPicker.launch() + galleryMultiMediaPicker.launch() } MessageComposerEvent.PickAttachmentSource.FromFiles -> localCoroutineScope.launch { showAttachmentSourcePicker = false @@ -622,6 +625,29 @@ class MessageComposerPresenter( messageComposerContext.composerMode = MessageComposerMode.Normal } + private fun handlePickedMediaList( + uris: List, + ) { + if (uris.isEmpty()) return + if (uris.size == 1) { + handlePickedMedia(uris.first()) + return + } + val attachments = uris.map { uri -> + val localMedia = localMediaFactory.createFromUri( + uri = uri, + mimeType = null, + name = null, + formattedFileSize = null, + ) + Attachment.Media(localMedia) + }.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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt index be573fa92f5..6a2ec0be3c1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt @@ -124,7 +124,7 @@ class ThreadedMessagesNode( } interface Callback : Plugin { - fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean + fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean, galleryItemIndex: Int? = null): Boolean fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) fun navigateToRoomMemberDetails(userId: UserId) fun handlePermalinkClick(data: PermalinkData) @@ -286,6 +286,20 @@ class ThreadedMessagesNode( } } == true }, + onGalleryItemClick = { isLive, event, index -> + timelineController?.let { controller -> + if (isLive) { + callback.handleEventClick(controller.mainTimelineMode(), event, canUseOverlay, index) + } else { + val detachedTimelineMode = controller.detachedTimelineMode() + if (detachedTimelineMode != null) { + callback.handleEventClick(detachedTimelineMode, event, canUseOverlay, index) + } else { + false + } + } + } == true + }, onUserDataClick = callback::navigateToRoomMemberDetails, onLinkClick = { url, customTab -> onLinkClick( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index 41a828abb49..eed0b1586c4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -97,6 +97,7 @@ fun TimelineView( onUserDataClick: (MatrixUser) -> Unit, onLinkClick: (Link) -> Unit, onContentClick: (TimelineItem.Event) -> Unit, + onGalleryItemClick: ((TimelineItem.Event, Int) -> Unit)? = null, onMessageLongClick: (TimelineItem.Event) -> Unit, onSwipeToReply: (TimelineItem.Event) -> Unit, onReactionClick: (emoji: String, TimelineItem.Event) -> Unit, @@ -179,6 +180,7 @@ fun TimelineView( onLinkClick = onLinkClick, onLinkLongClick = ::onLinkLongClick, onContentClick = onContentClick, + onGalleryItemClick = onGalleryItemClick, onLongClick = onMessageLongClick, inReplyToClick = ::inReplyToClick, onReactionClick = onReactionClick, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index 936028cbd63..22febcc8002 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -70,12 +70,16 @@ 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 import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent +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.aTimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation @@ -156,6 +160,7 @@ fun TimelineItemEventRow( onMoreReactionsClick: (eventId: TimelineItem.Event) -> Unit, onReadReceiptClick: (event: TimelineItem.Event) -> Unit, onSwipeToReply: () -> Unit, + onGalleryItemClick: ((Int) -> Unit)? = null, eventSink: (TimelineEvent.TimelineItemEvent) -> Unit, modifier: Modifier = Modifier, eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = { contentModifier, onContentLayoutChange -> @@ -166,13 +171,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, ) }, ) { @@ -778,6 +784,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() @@ -792,6 +800,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 @@ -835,6 +845,255 @@ internal fun TimelineItemEventRowPreview() = ElementPreview { } } +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithGalleryPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = aTimelineItemGalleryContent( + caption = "My vacation photos", + items = listOf( + io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(), + io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(), + io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(), + io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(), + ), + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithGalleryTwoItemsPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = aTimelineItemGalleryContent( + items = listOf( + io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(), + io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(), + ), + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithGalleryThreeItemsPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = aTimelineItemGalleryContent( + caption = "Three photos", + items = listOf( + io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(), + io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(), + io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(), + ), + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithGalleryManyItemsPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = aTimelineItemGalleryContent( + caption = "Many photos", + items = (1..8).map { io.element.android.features.messages.impl.timeline.model.event.aGalleryItem() }, + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithGalleryVideoItemsPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = aTimelineItemGalleryContent( + caption = "Videos", + items = listOf( + io.element.android.features.messages.impl.timeline.model.event.aGalleryItem( + isVideo = true, + duration = kotlin.time.Duration.parse("PT1M30S") + ), + io.element.android.features.messages.impl.timeline.model.event.aGalleryItem( + isVideo = true, + duration = kotlin.time.Duration.parse("PT45S") + ), + io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(), + ), + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithAttachmentsPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = aTimelineItemAttachmentsContent( + caption = "Documents", + attachments = listOf( + io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem( + filename = "document.pdf", + fileExtension = "pdf", + ), + io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem( + filename = "presentation.pdf", + fileExtension = "pdf", + ), + io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem( + filename = "spreadsheet.xlsx", + fileExtension = "xlsx", + ), + ), + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithAttachmentsImagesPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = aTimelineItemAttachmentsContent( + caption = "Photos", + attachments = listOf( + io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem( + filename = "photo1.jpg", + fileExtension = "jpg", + hasThumbnail = true, + ), + io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem( + filename = "photo2.jpg", + fileExtension = "jpg", + hasThumbnail = true, + ), + io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem( + filename = "photo3.jpg", + fileExtension = "jpg", + hasThumbnail = true, + ), + ), + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithAttachmentsVideosPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = aTimelineItemAttachmentsContent( + caption = "Videos", + attachments = listOf( + io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem( + filename = "video1.mp4", + fileExtension = "mp4", + hasThumbnail = true, + fileSize = 150_000_000L, + formattedFileSize = "150MB", + ), + io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem( + filename = "video2.mov", + fileExtension = "mov", + hasThumbnail = true, + fileSize = 85_000_000L, + formattedFileSize = "85MB", + ), + ), + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithAttachmentsAudioPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = aTimelineItemAttachmentsContent( + caption = "Audio", + attachments = listOf( + io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem( + filename = "recording.mp3", + fileExtension = "mp3", + fileSize = 4_500_000L, + formattedFileSize = "4.5MB", + ), + io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem( + filename = "voice_message.m4a", + fileExtension = "m4a", + fileSize = 1_200_000L, + formattedFileSize = "1.2MB", + ), + ), + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + } +} + @PreviewsDayNight @Composable internal fun TimelineItemEventRowWithThreadSummaryPreview() = ElementPreview { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index a02413d5342..87f1a1b0f9f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -65,6 +65,7 @@ internal fun TimelineItemRow( onLinkClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit, onContentClick: (TimelineItem.Event) -> Unit, + onGalleryItemClick: ((TimelineItem.Event, Int) -> Unit)? = null, onLongClick: (TimelineItem.Event) -> Unit, inReplyToClick: (EventId) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit, @@ -81,12 +82,13 @@ internal fun TimelineItemRow( hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId), onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, onContentClick = { onContentClick(event) }, + onGalleryItemClick = { index -> onGalleryItemClick?.invoke(event, index) }, onLongClick = { onLongClick(event) }, onLinkClick = onLinkClick, onLinkLongClick = onLinkLongClick, eventSink = eventSink, modifier = contentModifier, - onContentLayoutChange = onContentLayoutChange + onContentLayoutChange = onContentLayoutChange, ) }, ) { @@ -180,6 +182,7 @@ internal fun TimelineItemRow( onMoreReactionsClick = onMoreReactionsClick, onReadReceiptClick = onReadReceiptClick, onSwipeToReply = { onSwipeToReply(timelineItem) }, + onGalleryItemClick = { index -> onGalleryItemClick?.invoke(timelineItem, index) }, eventSink = eventSink, eventContentView = { contentModifier, onContentLayoutChange -> eventContentView(timelineItem, contentModifier, onContentLayoutChange) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt new file mode 100644 index 00000000000..a12104e504c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt @@ -0,0 +1,305 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.event + +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.fillMaxWidth +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.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.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.ui.strings.CommonStrings + +@Composable +fun TimelineItemAttachmentsListView( + content: TimelineItemAttachmentsContent, + onContentClick: ((Int) -> Unit)?, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, + modifier: Modifier = Modifier, +) { + if (content.attachments.isEmpty()) return + + Column(modifier = modifier) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + content.attachments.forEachIndexed { index, attachment -> + Column { + if (index > 0) { + HorizontalDivider( + modifier = Modifier, + thickness = 1.dp, + ) + } + AttachmentListItem( + attachment = attachment, + onClick = onContentClick?.let { { it(index) } }, + onContentLayoutChange = onContentLayoutChange, + ) + } + } + } + + if (content.showCaption) { + HorizontalDivider( + modifier = Modifier, + thickness = 1.dp, + ) + val caption = if (LocalInspectionMode.current) { + android.text.SpannedString(content.caption) + } else { + (content.formattedCaption ?: android.text.SpannedString(content.caption)).let { + if (it is String) it else android.text.SpannedString(content.caption) + } + } + CompositionLocalProvider( + LocalContentColor provides ElementTheme.colors.textPrimary, + LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular + ) { + Text( + modifier = Modifier + .padding(top = 8.dp, start = 4.dp, end = 4.dp) + .widthIn(min = 120.dp), + text = caption.toString(), + style = ElementTheme.typography.fontBodyLgRegular, + maxLines = 5, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun AttachmentListItem( + attachment: AttachmentItem, + onClick: (() -> Unit)?, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, + modifier: Modifier = Modifier, +) { + val iconSize = 36.dp + val thumbnailSize = 36L + val spacing = 8.dp + val hasCaption = false + + Row( + modifier = modifier + .padding(vertical = 6.dp) + .then( + if (onClick != null) { + Modifier.clickable(onClick = onClick) + } else { + Modifier + } + ), + 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 = androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.3f), + shape = RoundedCornerShape(4.dp) + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = CompoundIcons.VideoCallSolid(), + contentDescription = stringResource(CommonStrings.common_video), + tint = androidx.compose.ui.graphics.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, + onTextLayout = if (hasCaption) { + {} + } else { + ContentAvoidingLayout.measureLastTextLine( + onContentLayoutChange = onContentLayoutChange, + extraWidth = iconSize + spacing + ) + }, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemAttachmentsListViewPreview() = ElementPreview { + TimelineItemAttachmentsListView( + content = TimelineItemAttachmentsContent( + body = "Files", + caption = null, + formattedCaption = null, + isEdited = false, + attachments = listOf( + AttachmentItem( + filename = "document.pdf", + mimeType = "application/pdf", + mediaSource = io.element.android.libraries.matrix.api.media.MediaSource(url = "", json = ""), + thumbnailSource = null, + fileSize = null, + formattedFileSize = "2.5 MB", + fileExtension = "PDF", + ), + AttachmentItem( + filename = "photo.jpg", + mimeType = "image/jpeg", + mediaSource = io.element.android.libraries.matrix.api.media.MediaSource(url = "", json = ""), + thumbnailSource = io.element.android.libraries.matrix.api.media.MediaSource(url = "thumb", json = ""), + fileSize = null, + formattedFileSize = "1.2 MB", + fileExtension = "JPG", + ), + AttachmentItem( + filename = "spreadsheet.xlsx", + mimeType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + mediaSource = io.element.android.libraries.matrix.api.media.MediaSource(url = "", json = ""), + thumbnailSource = null, + fileSize = null, + formattedFileSize = "450 KB", + fileExtension = "XLSX", + ), + ), + ), + onContentClick = {}, + onContentLayoutChange = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemAttachmentsListViewWithCaptionPreview() = ElementPreview { + TimelineItemAttachmentsListView( + content = TimelineItemAttachmentsContent( + body = "Files", + caption = "Important documents", + formattedCaption = null, + isEdited = false, + attachments = listOf( + AttachmentItem( + filename = "report.pdf", + mimeType = "application/pdf", + mediaSource = io.element.android.libraries.matrix.api.media.MediaSource(url = "", json = ""), + thumbnailSource = null, + fileSize = null, + formattedFileSize = "3.2 MB", + fileExtension = "PDF", + ), + AttachmentItem( + filename = "notes.txt", + mimeType = "text/plain", + mediaSource = io.element.android.libraries.matrix.api.media.MediaSource(url = "", json = ""), + thumbnailSource = null, + fileSize = null, + formattedFileSize = "12 KB", + fileExtension = "TXT", + ), + ), + ), + onContentClick = {}, + onContentLayoutChange = {}, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt index 2044796889f..e4c16aa080c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt @@ -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)? = null, onLongClick: (() -> Unit)?, onShowContentClick: () -> Unit, onLinkClick: (Link) -> Unit, @@ -90,6 +93,21 @@ fun TimelineItemEventContentView( onContentLayoutChange = onContentLayoutChange, modifier = modifier, ) + is TimelineItemGalleryContent -> TimelineItemGalleryView( + content = content, + onContentClick = { index -> onGalleryItemClick?.invoke(index) }, + onLongClick = onLongClick, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, + onContentLayoutChange = onContentLayoutChange, + modifier = modifier, + ) + is TimelineItemAttachmentsContent -> TimelineItemAttachmentsListView( + content = content, + onContentClick = { index -> onGalleryItemClick?.invoke(index) }, + onContentLayoutChange = {}, + modifier = modifier, + ) is TimelineItemStickerContent -> TimelineItemStickerView( content = content, hideMediaContent = hideMediaContent, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt new file mode 100644 index 00000000000..02d550d00f2 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt @@ -0,0 +1,650 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.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.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 kotlin.time.Duration.Companion.seconds + +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, + onContentClick: ((Int) -> Unit)?, + onLongClick: (() -> Unit)?, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, + modifier: Modifier = Modifier, +) { + if (content.items.isEmpty()) return + + val totalItems = content.items.size + val showOverflow = totalItems > MAX_TILES - 1 + val overflowCount = totalItems - (MAX_TILES - 1) + + Column(modifier = modifier) { + val containerModifier = Modifier.clip(RoundedCornerShape(GROUP_CORNER_RADIUS)) + + Column( + modifier = containerModifier.width(GALLERY_WIDTH), + verticalArrangement = Arrangement.spacedBy(GRID_SPACING), + ) { + when { + totalItems == 1 -> { + SingleItemLayout( + item = content.items[0], + onClick = onContentClick?.let { { it(0) } }, + onLongClick = onLongClick, + ) + } + totalItems == 2 -> { + TwoItemLayout( + items = content.items, + onContentClick = onContentClick, + onLongClick = onLongClick, + ) + } + totalItems == 3 -> { + ThreeItemLayout( + items = content.items, + onContentClick = onContentClick, + onLongClick = onLongClick, + ) + } + totalItems >= 4 -> { + FourPlusItemLayout( + items = content.items, + showOverflow = showOverflow, + overflowCount = overflowCount, + onContentClick = onContentClick, + 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: List, + onContentClick: ((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 = onContentClick?.let { { it(index) } }, + onLongClick = onLongClick, + modifier = Modifier + .fillMaxWidth() + .height(TWO_IMAGE_ROW_HEIGHT), + ) + } + } +} + +@Composable +private fun ThreeItemLayout( + items: List, + onContentClick: ((Int) -> Unit)?, + onLongClick: (() -> Unit)?, +) { + Column( + modifier = Modifier.width(GALLERY_WIDTH), + verticalArrangement = Arrangement.spacedBy(GRID_SPACING), + ) { + GalleryItemCell( + item = items[0], + isLast = false, + remainingCount = 0, + onClick = onContentClick?.let { { it(0) } }, + onLongClick = onLongClick, + modifier = Modifier + .fillMaxWidth() + .height(SINGLE_IMAGE_HEIGHT), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(GRID_SPACING), + ) { + GalleryItemCell( + item = items[1], + isLast = false, + remainingCount = 0, + onClick = onContentClick?.let { { it(1) } }, + onLongClick = onLongClick, + modifier = Modifier + .weight(1f) + .height(TWO_IMAGE_ROW_HEIGHT), + ) + GalleryItemCell( + item = items[2], + isLast = false, + remainingCount = 0, + onClick = onContentClick?.let { { it(2) } }, + onLongClick = onLongClick, + modifier = Modifier + .weight(1f) + .height(TWO_IMAGE_ROW_HEIGHT), + ) + } + } +} + +@Composable +private fun FourPlusItemLayout( + items: List, + showOverflow: Boolean, + overflowCount: Int, + onContentClick: ((Int) -> Unit)?, + onLongClick: (() -> Unit)?, +) { + Column( + modifier = Modifier.width(GALLERY_WIDTH), + verticalArrangement = Arrangement.spacedBy(GRID_SPACING), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(GRID_SPACING), + ) { + GalleryItemCell( + item = items[0], + isLast = false, + remainingCount = 0, + onClick = onContentClick?.let { { it(0) } }, + onLongClick = onLongClick, + modifier = Modifier + .weight(1f) + .height(TWO_IMAGE_ROW_HEIGHT), + ) + GalleryItemCell( + item = items[1], + isLast = false, + remainingCount = 0, + onClick = onContentClick?.let { { it(1) } }, + 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 = onContentClick?.let { { it(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) + .then( + if (onClick != null) { + Modifier.combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + ) + } else { + Modifier + } + ), + contentAlignment = Alignment.Center, + ) { + AsyncImage( + modifier = Modifier.fillMaxSize(), + model = item.thumbnailMediaRequestData, + contentScale = ContentScale.Crop, + alignment = Alignment.Center, + contentDescription = item.filename, + ) + + if (item.isVideo) { + 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() = ElementPreview { + TimelineItemGalleryView( + content = TimelineItemGalleryContent( + body = "Gallery", + caption = "My vacation photos", + formattedCaption = null, + isEdited = false, + items = listOf( + createSampleGalleryItem(isVideo = false), + createSampleGalleryItem(isVideo = true, duration = 65.seconds), + createSampleGalleryItem(isVideo = false), + createSampleGalleryItem(isVideo = false), + ), + ), + onContentClick = {}, + onLongClick = {}, + onLinkClick = {}, + onLinkLongClick = {}, + onContentLayoutChange = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemGalleryViewSingleItemPreview() = ElementPreview { + TimelineItemGalleryView( + content = TimelineItemGalleryContent( + body = "Gallery", + caption = null, + formattedCaption = null, + isEdited = false, + items = listOf( + createSampleGalleryItem(isVideo = false), + ), + ), + onContentClick = {}, + onLongClick = {}, + onLinkClick = {}, + onLinkLongClick = {}, + onContentLayoutChange = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemGalleryViewTwoLandscapePreview() = ElementPreview { + TimelineItemGalleryView( + content = TimelineItemGalleryContent( + body = "Gallery", + caption = null, + formattedCaption = null, + isEdited = false, + items = listOf( + createSampleGalleryItem(width = 1920, height = 1080), + createSampleGalleryItem(width = 1600, height = 900), + ), + ), + onContentClick = {}, + onLongClick = {}, + onLinkClick = {}, + onLinkLongClick = {}, + onContentLayoutChange = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemGalleryViewTwoPortraitPreview() = ElementPreview { + TimelineItemGalleryView( + content = TimelineItemGalleryContent( + body = "Gallery", + caption = null, + formattedCaption = null, + isEdited = false, + items = listOf( + createSampleGalleryItem(width = 1080, height = 1920), + createSampleGalleryItem(width = 900, height = 1600), + ), + ), + onContentClick = {}, + onLongClick = {}, + onLinkClick = {}, + onLinkLongClick = {}, + onContentLayoutChange = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemGalleryViewTwoMixedPreview() = ElementPreview { + TimelineItemGalleryView( + content = TimelineItemGalleryContent( + body = "Gallery", + caption = null, + formattedCaption = null, + isEdited = false, + items = listOf( + createSampleGalleryItem(width = 1920, height = 1080), + createSampleGalleryItem(width = 1080, height = 1920), + ), + ), + onContentClick = {}, + onLongClick = {}, + onLinkClick = {}, + onLinkLongClick = {}, + onContentLayoutChange = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemGalleryViewThreeItemsPreview() = ElementPreview { + TimelineItemGalleryView( + content = TimelineItemGalleryContent( + body = "Gallery", + caption = null, + formattedCaption = null, + isEdited = false, + items = listOf( + createSampleGalleryItem(isVideo = true, duration = 45.seconds), + createSampleGalleryItem(isVideo = false), + createSampleGalleryItem(isVideo = false), + ), + ), + onContentClick = {}, + onLongClick = {}, + onLinkClick = {}, + onLinkLongClick = {}, + onContentLayoutChange = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemGalleryViewThree2L1PPreview() = ElementPreview { + TimelineItemGalleryView( + content = TimelineItemGalleryContent( + body = "Gallery", + caption = "2 landscape + 1 portrait", + formattedCaption = null, + isEdited = false, + items = listOf( + createSampleGalleryItem(width = 1920, height = 1080), + createSampleGalleryItem(width = 1600, height = 900), + createSampleGalleryItem(width = 1080, height = 1920), + ), + ), + onContentClick = {}, + onLongClick = {}, + onLinkClick = {}, + onLinkLongClick = {}, + onContentLayoutChange = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemGalleryViewThree1L2PPreview() = ElementPreview { + TimelineItemGalleryView( + content = TimelineItemGalleryContent( + body = "Gallery", + caption = "1 landscape + 2 portrait", + formattedCaption = null, + isEdited = false, + items = listOf( + createSampleGalleryItem(width = 1920, height = 1080), + createSampleGalleryItem(width = 1080, height = 1920), + createSampleGalleryItem(width = 900, height = 1600), + ), + ), + onContentClick = {}, + onLongClick = {}, + onLinkClick = {}, + onLinkLongClick = {}, + onContentLayoutChange = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemGalleryViewFiveItemsPreview() = ElementPreview { + TimelineItemGalleryView( + content = TimelineItemGalleryContent( + body = "Gallery", + caption = null, + formattedCaption = null, + isEdited = false, + items = listOf( + createSampleGalleryItem(isVideo = false), + createSampleGalleryItem(isVideo = false), + createSampleGalleryItem(isVideo = true, duration = 120.seconds), + createSampleGalleryItem(isVideo = false), + createSampleGalleryItem(isVideo = false), + ), + ), + onContentClick = {}, + onLongClick = {}, + onLinkClick = {}, + onLinkLongClick = {}, + onContentLayoutChange = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemGalleryViewManyItemsPreview() = ElementPreview { + TimelineItemGalleryView( + content = TimelineItemGalleryContent( + body = "Gallery", + caption = "Many photos", + formattedCaption = null, + isEdited = false, + items = (1..12).map { createSampleGalleryItem(isVideo = it == 3) }, + ), + onContentClick = {}, + onLongClick = {}, + onLinkClick = {}, + onLinkLongClick = {}, + onContentLayoutChange = {}, + ) +} + +private fun createSampleGalleryItem( + isVideo: Boolean = false, + width: Int = 400, + height: Int = 300, + duration: kotlin.time.Duration = kotlin.time.Duration.ZERO, +): GalleryItem { + return GalleryItem( + filename = "photo.jpg", + mimeType = "image/jpeg", + mediaSource = io.element.android.libraries.matrix.api.media.MediaSource(url = "", json = ""), + thumbnailSource = null, + width = width, + height = height, + thumbnailWidth = width, + thumbnailHeight = height, + blurhash = null, + isVideo = isVideo, + isAudio = false, + isFile = false, + duration = duration, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt index 8d1ef18f39f..35cdc9e6c32 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt @@ -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, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt index e2e5d0c03ed..27166faa94f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt @@ -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,151 @@ 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, + 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, + isVideo = false, + isAudio = false, + isFile = false, + ) + } + is GalleryItemType.Video -> { + GalleryItem( + filename = item.content.filename, + mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream, + mediaSource = item.content.source, + 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, + isVideo = true, + isAudio = false, + isFile = false, + duration = item.content.info?.duration ?: kotlin.time.Duration.ZERO, + ) + } + is GalleryItemType.Audio -> { + GalleryItem( + filename = item.content.filename, + mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream, + mediaSource = item.content.source, + thumbnailSource = null, + width = null, + height = null, + thumbnailWidth = null, + thumbnailHeight = null, + blurhash = null, + isVideo = false, + isAudio = true, + isFile = false, + ) + } + is GalleryItemType.File -> { + GalleryItem( + filename = item.content.filename, + mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream, + mediaSource = item.content.source, + thumbnailSource = item.content.info?.thumbnailSource, + width = null, + height = null, + thumbnailWidth = null, + thumbnailHeight = null, + blurhash = null, + isVideo = false, + isAudio = false, + isFile = true, + ) + } + is GalleryItemType.Other -> null + } + } + val hasPreviews = galleryItems.any { it.thumbnailSource != null } + val isMediaGallery = galleryItems.all { item -> + item.isVideo || (!item.isAudio && !item.isFile) + } + if (isMediaGallery && hasPreviews) { + TimelineItemGalleryContent( + body = messageType.body, + caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() }, + formattedCaption = formattedCaption, + isEdited = content.isEdited, + items = galleryItems, + ) + } 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, + ) + } + } is OtherMessageType -> { val body = messageType.body.trimEnd() TimelineItemTextContent( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt index 837692ae6f3..8ebfcfc9d2f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt @@ -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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt index c9adba21da1..1cbc2ec29c6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt @@ -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 } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAttachmentsContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAttachmentsContent.kt new file mode 100644 index 00000000000..32264e52e0f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAttachmentsContent.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model.event + +import io.element.android.libraries.matrix.api.media.MediaSource + +data class TimelineItemAttachmentsContent( + val body: String, + val caption: String?, + val formattedCaption: CharSequence?, + override val isEdited: Boolean, + val attachments: List, +) : 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, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt index 9c4c48d11ea..967e7641675 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt @@ -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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt index 52e008e121c..0e4675c3893 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt @@ -111,3 +111,83 @@ fun aTimelineItemStateEventContent( ) = TimelineItemStateEventContent( body = body, ) + +fun aTimelineItemGalleryContent( + body: String = "Gallery", + caption: String? = null, + items: List = listOf( + aGalleryItem(), + aGalleryItem(), + aGalleryItem(), + aGalleryItem(), + ), +) = TimelineItemGalleryContent( + body = body, + caption = caption, + formattedCaption = null, + isEdited = false, + items = items, +) + +fun aGalleryItem( + filename: String = "photo.jpg", + width: Int = 400, + height: Int = 300, + isVideo: Boolean = false, + isAudio: Boolean = false, + isFile: Boolean = false, + duration: kotlin.time.Duration = kotlin.time.Duration.ZERO, +) = GalleryItem( + filename = filename, + mimeType = when { + isVideo -> "video/mp4" + isAudio -> "audio/mpeg" + isFile -> "application/pdf" + else -> "image/jpeg" + }, + mediaSource = io.element.android.libraries.matrix.api.media.MediaSource(url = "", json = ""), + thumbnailSource = null, + width = width, + height = height, + thumbnailWidth = width, + thumbnailHeight = height, + blurhash = null, + isVideo = isVideo, + isAudio = isAudio, + isFile = isFile, + duration = duration, +) + +fun aTimelineItemAttachmentsContent( + body: String = "Attachments", + caption: String? = null, + attachments: List = 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, +) + +fun anAttachmentItem( + filename: String = "file.pdf", + fileExtension: String = "pdf", + fileSize: Long = 1_000_000L, + formattedFileSize: String = "1MB", + hasThumbnail: Boolean = false, +) = AttachmentItem( + filename = filename, + mimeType = when { + hasThumbnail -> "image/jpeg" + else -> "application/$fileExtension" + }, + mediaSource = io.element.android.libraries.matrix.api.media.MediaSource(url = "", json = ""), + thumbnailSource = if (hasThumbnail) io.element.android.libraries.matrix.api.media.MediaSource(url = "", json = "") else null, + fileSize = fileSize, + formattedFileSize = formattedFileSize, + fileExtension = fileExtension, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt new file mode 100644 index 00000000000..1fb48763627 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.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 kotlin.time.Duration + +data class TimelineItemGalleryContent( + val body: String, + val caption: String?, + val formattedCaption: CharSequence?, + override val isEdited: Boolean, + val items: List, +) : TimelineItemEventContent, TimelineItemEventMutableContent { + override val type: String = "TimelineItemGalleryContent" + + val showCaption = caption != null +} + +data class GalleryItem( + val filename: String, + val mimeType: String, + val mediaSource: MediaSource, + val thumbnailSource: MediaSource?, + val width: Int?, + val height: Int?, + val thumbnailWidth: Int?, + val thumbnailHeight: Int?, + val blurhash: String?, + val isVideo: Boolean, + val isAudio: Boolean, + val isFile: Boolean, + val duration: Duration = Duration.ZERO, +) { + 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 + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineItem.kt index 5a5363f0c68..1d14b5991bd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineItem.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineItem.kt @@ -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 @@ -36,6 +38,8 @@ fun TimelineItem.mustBeProtected(): Boolean { return when (this) { is TimelineItem.Event -> when (content) { is TimelineItemImageContent, + is TimelineItemGalleryContent, + is TimelineItemAttachmentsContent, is TimelineItemVideoContent, is TimelineItemStickerContent -> true is TimelineItemAudioContent, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt index 210e1235956..218da22fd75 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt @@ -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_image) + is TimelineItemAttachmentsContent -> { + val count = content.attachments.size + val extensions = content.attachments.take(3).joinToString(", ") { it.fileExtension } + if (count == 1) { + extensions + } else { + "$extensions +${count - 3}" + } + } is TimelineItemLegacyCallInviteContent -> context.getString(CommonStrings.common_unsupported_call) is TimelineItemRtcNotificationContent -> when (content.state) { is RtcNotificationState.Declined -> { diff --git a/features/messages/impl/src/main/res/values/temporary.xml b/features/messages/impl/src/main/res/values/temporary.xml new file mode 100644 index 00000000000..e78564dc8f3 --- /dev/null +++ b/features/messages/impl/src/main/res/values/temporary.xml @@ -0,0 +1,9 @@ + + + + "%1$d item selected" + "%1$d items selected" + + "%1$d of %2$d" + Attachments + diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt index 140d4f8ea38..410029c7db3 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt @@ -123,7 +123,7 @@ class AttachmentsPreviewPresenterTest { 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.ReadyToUpload(listOf(mediaUploadInfo))) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done) sendFileResult.assertions().isCalledOnce() @@ -159,7 +159,7 @@ class AttachmentsPreviewPresenterTest { 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.ReadyToUpload(listOf(mediaUploadInfo))) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done) sendFileResult.assertions().isCalledOnce() @@ -195,7 +195,7 @@ class AttachmentsPreviewPresenterTest { // 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.ReadyToUpload(listOf(mediaUploadInfo))) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done) sendFileResult.assertions().isCalledOnce() @@ -413,18 +413,15 @@ class AttachmentsPreviewPresenterTest { 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.ReadyToUpload(listOf(mediaUploadInfo))) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo)) - // Check that the onDoneListener is called so the screen would be dismissed - onDoneListenerResult.assertions().isCalledOnce() - val failureState = awaitItem() assertThat(failureState.sendActionState).isEqualTo(SendActionState.Failure(failure, 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))) } } @@ -446,15 +443,12 @@ class AttachmentsPreviewPresenterTest { 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.ReadyToUpload(listOf(mediaUploadInfo))) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(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() } } @@ -586,7 +580,7 @@ class AttachmentsPreviewPresenterTest { 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() } @@ -627,7 +621,7 @@ class AttachmentsPreviewPresenterTest { rotatedState.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() @@ -871,7 +865,7 @@ class AttachmentsPreviewPresenterTest { videoCompressionPresetSelector: VideoCompressionPresetSelector = VideoCompressionPresetSelector(), ): AttachmentsPreviewPresenter { return AttachmentsPreviewPresenter( - attachment = aMediaAttachment(localMedia, sendAsFile = sendAsFile), + attachments = persistentListOf(aMediaAttachment(localMedia)), onDoneListener = onDoneListener, mediaSenderFactory = MediaSenderFactory { timelineMode -> DefaultMediaSender( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/SendActionStateTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/SendActionStateTest.kt index 208d9cc7c0a..10b9b918991 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/SendActionStateTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/SendActionStateTest.kt @@ -25,7 +25,7 @@ class SendActionStateTest { @Test fun `mediaUploadInfo() should return the value from ReadyToUpload class`() { val mediaUploadInfo: MediaUploadInfo = aMediaUploadInfo() - val state: SendActionState = SendActionState.Sending.ReadyToUpload(mediaInfo = aMediaUploadInfo()) + val state: SendActionState = SendActionState.Sending.ReadyToUpload(mediaInfos = listOf(aMediaUploadInfo())) assertThat(state.mediaUploadInfo()).isEqualTo(mediaUploadInfo) } diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt index c8afba36035..b4b95ff1da7 100644 --- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt @@ -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_image) + } is NoticeMessageType -> { messageType.body } diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt index e8a462da163..c4997d2a38e 100644 --- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt @@ -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_image)) + } is NoticeMessageType -> { messageType.body } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/GalleryItemInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/GalleryItemInfo.kt new file mode 100644 index 00000000000..446febdbe52 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/GalleryItemInfo.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.media + +import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody +import java.io.File + +sealed interface GalleryItemInfo { + val file: File + val caption: String? + val formattedCaption: FormattedBody? + + data class Image( + override val file: File, + val imageInfo: ImageInfo, + val thumbnailFile: File?, + override val caption: String?, + override val formattedCaption: FormattedBody?, + ) : GalleryItemInfo + + data class Video( + override val file: File, + val videoInfo: VideoInfo, + val thumbnailFile: File?, + override val caption: String?, + override val formattedCaption: FormattedBody?, + ) : GalleryItemInfo + + data class Audio( + override val file: File, + val audioInfo: AudioInfo, + override val caption: String?, + override val formattedCaption: FormattedBody?, + ) : GalleryItemInfo + + data class MediaFile( + override val file: File, + val fileInfo: FileInfo, + override val caption: String?, + override val formattedCaption: FormattedBody?, + ) : GalleryItemInfo +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt index fe73230dce0..35d1acdf2f6 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt @@ -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 + suspend fun sendGallery( + items: List, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId?, + ): Result + suspend fun redactEvent(eventOrTransactionId: EventOrTransactionId, reason: String?): Result suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt index 0cc5ca0ff74..eff49c88ef3 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt @@ -102,6 +102,21 @@ data class TextMessageType( val formatted: FormattedBody? ) : MessageType +data class GalleryMessageType( + val body: String, + val formatted: FormattedBody?, + val items: List, +) : 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, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/FormattedBody.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/FormattedBody.kt new file mode 100644 index 00000000000..015d1bce047 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/FormattedBody.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.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("") + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryItemInfoMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryItemInfoMapper.kt new file mode 100644 index 00000000000..fa46d19c6f4 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryItemInfoMapper.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.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 = caption, + formattedCaption = formattedCaption?.map(), + thumbnailSource = thumbnailFile?.path?.let(RustUploadSource::File), + ) + } + is GalleryItemInfo.Video -> { + RustGalleryItemInfo.Video( + videoInfo = videoInfo.map(), + source = RustUploadSource.File(file.path), + caption = caption, + formattedCaption = formattedCaption?.map(), + thumbnailSource = thumbnailFile?.path?.let(RustUploadSource::File), + ) + } + is GalleryItemInfo.Audio -> { + RustGalleryItemInfo.Audio( + audioInfo = audioInfo.map(), + source = RustUploadSource.File(file.path), + caption = caption, + formattedCaption = formattedCaption?.map(), + ) + } + is GalleryItemInfo.MediaFile -> { + RustGalleryItemInfo.File( + fileInfo = fileInfo.map(), + source = RustUploadSource.File(file.path), + caption = caption, + formattedCaption = formattedCaption?.map(), + ) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryMediaUploadHandlerImpl.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryMediaUploadHandlerImpl.kt new file mode 100644 index 00000000000..ee3b1140d34 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryMediaUploadHandlerImpl.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.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, + private val sendGalleryJoinHandle: SendGalleryJoinHandle, +) : MediaUploadHandler { + override suspend fun await(): Result = + runCatchingExceptions { + sendGalleryJoinHandle.join() + } + .also { cleanUpFiles() } + + override fun cancel() { + sendGalleryJoinHandle.cancel() + cleanUpFiles() + } + + private fun cleanUpFiles() { + filesToUpload.forEach { file -> file.safeDelete() } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt index 7e65a1cc5cb..84fd2e80e8e 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt @@ -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), diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt index e75091eadcb..c52c8f1fa16 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt @@ -218,6 +218,7 @@ class JoinedRustRoom( RoomMessageEventMessageType.IMAGE, RoomMessageEventMessageType.VIDEO, RoomMessageEventMessageType.AUDIO, + RoomMessageEventMessageType.GALLERY, ) ) is CreateTimelineParams.Focused, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt index d44676d34ac..64fb71e7024 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt @@ -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 @@ -69,6 +71,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 @@ -551,6 +554,42 @@ class RustTimeline( } } + override suspend fun sendGallery( + items: List, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId?, + ): Result { + 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, handle: () -> org.matrix.rustcomponents.sdk.SendGalleryJoinHandle): Result { + return runCatchingExceptions { + GalleryMediaUploadHandlerImpl(files, handle()) + } + } + override suspend fun createPoll( question: String, answers: List, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt index e382bce8db9..0cc213adfba 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt @@ -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, + ) } } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt index fcc7057dbe5..96b0c7d5413 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt @@ -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, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId?, + ) -> Result = { _, _, _, _ -> + Result.success(FakeMediaUploadHandler()) + } + + override suspend fun sendGallery( + items: List, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId?, + ): Result = simulateLongTask { + sendGalleryLambda( + items, + caption, + formattedCaption, + inReplyToEventId, + ) + } + var sendLocationLambda: ( body: String, geoUri: String, diff --git a/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerProvider.kt b/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerProvider.kt index 7ae35e82180..1bab6936094 100644 --- a/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerProvider.kt +++ b/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerProvider.kt @@ -23,12 +23,23 @@ interface PickerProvider { onResult: (Uri?) -> Unit ): PickerLauncher + @Composable + fun registerGalleryMultiPicker( + onResult: (uris: List) -> Unit + ): PickerLauncher> + @Composable fun registerFilePicker( mimeType: String, onResult: (uri: Uri?, mimeType: String?) -> Unit, ): PickerLauncher + @Composable + fun registerFileMultiPicker( + mimeType: String, + onResult: (uris: List) -> Unit, + ): PickerLauncher, List> + @Composable fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher diff --git a/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerType.kt b/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerType.kt index 9c69e6448b8..da84a66f49f 100644 --- a/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerType.kt +++ b/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerType.kt @@ -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 { fun getContract(): ActivityResultContract @@ -34,6 +37,13 @@ sealed interface PickerType { } } + data object ImageAndVideoMulti : PickerType> { + 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 { override fun getContract() = ActivityResultContracts.TakePicture() @@ -56,4 +66,13 @@ sealed interface PickerType { return mimeType } } + + data class FileMulti(val mimeType: String = MimeTypes.Any) : PickerType, List> { + override fun getContract(): ActivityResultContract, List> { + return ActivityResultContracts.OpenMultipleDocuments() + } + override fun getDefaultRequest(): Array { + return arrayOf(mimeType) + } + } } diff --git a/libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/DefaultPickerProvider.kt b/libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/DefaultPickerProvider.kt index 1b3df9c4267..45ac408d42e 100644 --- a/libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/DefaultPickerProvider.kt +++ b/libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/DefaultPickerProvider.kt @@ -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) -> Unit + ): PickerLauncher> { + 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) -> Unit, + ): PickerLauncher, List> { + 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 { - // 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 { diff --git a/libraries/mediapickers/test/src/main/kotlin/io/element/android/libraries/mediapickers/test/FakePickerProvider.kt b/libraries/mediapickers/test/src/main/kotlin/io/element/android/libraries/mediapickers/test/FakePickerProvider.kt index 98cbcec1ea5..5d3be8acd17 100644 --- a/libraries/mediapickers/test/src/main/kotlin/io/element/android/libraries/mediapickers/test/FakePickerProvider.kt +++ b/libraries/mediapickers/test/src/main/kotlin/io/element/android/libraries/mediapickers/test/FakePickerProvider.kt @@ -30,11 +30,21 @@ class FakePickerProvider : PickerProvider { return NoOpPickerLauncher { onResult(result) } } + @Composable + override fun registerGalleryMultiPicker(onResult: (uris: List) -> Unit): PickerLauncher> { + return NoOpPickerLauncher { onResult(result?.let { listOf(it) } ?: emptyList()) } + } + @Composable override fun registerFilePicker(mimeType: String, onResult: (Uri?, String?) -> Unit): PickerLauncher { return NoOpPickerLauncher { onResult(result, this.mimeType) } } + @Composable + override fun registerFileMultiPicker(mimeType: String, onResult: (uris: List) -> Unit): PickerLauncher, List> { + return NoOpPickerLauncher { onResult(result?.let { listOf(it) } ?: emptyList()) } + } + @Composable override fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher { return NoOpPickerLauncher { onResult(result) } diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt index 628e760c1e5..6cca73935e8 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt @@ -61,5 +61,12 @@ interface MediaSender { inReplyToEventId: EventId? = null, ): Result + suspend fun sendGallery( + mediaUploadInfos: List, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId?, + ): Result + fun cleanUp() } diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt index f0082a4d1b4..0f05bc315bc 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt @@ -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,40 @@ fun MediaUploadInfo.allFiles(): List { (this@allFiles as? MediaUploadInfo.Video)?.thumbnailFile, ) } + +fun MediaUploadInfo.toGalleryItemInfo(caption: String?, formattedCaption: String?): GalleryItemInfo { + return when (this) { + is MediaUploadInfo.Image -> GalleryItemInfo.Image( + file = file, + imageInfo = imageInfo, + thumbnailFile = thumbnailFile, + caption = caption, + formattedCaption = null, + ) + is MediaUploadInfo.Video -> GalleryItemInfo.Video( + file = file, + videoInfo = videoInfo, + thumbnailFile = thumbnailFile, + caption = caption, + formattedCaption = null, + ) + is MediaUploadInfo.Audio -> GalleryItemInfo.Audio( + file = file, + audioInfo = audioInfo, + caption = caption, + formattedCaption = null, + ) + is MediaUploadInfo.VoiceMessage -> GalleryItemInfo.Audio( + file = file, + audioInfo = audioInfo, + caption = caption, + formattedCaption = null, + ) + is MediaUploadInfo.AnyFile -> GalleryItemInfo.MediaFile( + file = file, + fileInfo = fileInfo, + caption = caption, + formattedCaption = null, + ) + } +} diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt index ea2cac29517..d7da5f01949 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt @@ -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, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId?, + ): Result { + val galleryLogId = "gallery[${mediaUploadInfos.size} items]" + Timber.d("Sending $galleryLogId") + return getTimeline().flatMap { timeline -> + val galleryItems = mediaUploadInfos.map { it.toGalleryItemInfo(null, null) } + timeline.sendGallery( + items = galleryItems, + caption = caption, + formattedCaption = formattedCaption, + inReplyToEventId = inReplyToEventId, + ) + } + .flatMapCatching { uploadHandler -> + uploadHandler.await() + } + .handleSendResult(galleryLogId) + } + private fun Result.handleSendResult(mediaId: String) = this .onFailure { error -> val job = ongoingUploadJobs.remove(Job) diff --git a/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaSender.kt b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaSender.kt index 1713f7b5eaf..6ef8da4ab6e 100644 --- a/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaSender.kt +++ b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaSender.kt @@ -19,6 +19,7 @@ class FakeMediaSender( private val sendPreProcessedMediaResult: () -> Result = { lambdaError() }, private val sendMediaResult: () -> Result = { lambdaError() }, private val sendVoiceMessageResult: () -> Result = { lambdaError() }, + private val sendGalleryResult: () -> Result = { 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, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId?, + ): Result { + return sendGalleryResult() + } + override fun cleanUp() { cleanUpResult() } diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/GalleryItemData.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/GalleryItemData.kt new file mode 100644 index 00000000000..fd3ec5637a0 --- /dev/null +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/GalleryItemData.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.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 isVideo: Boolean, + val isAudio: Boolean, + val isFile: Boolean, +) : Parcelable diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt index 536930b9682..05e6d1bdb50 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt @@ -42,6 +42,7 @@ interface MediaViewerEntryPoint : FeatureEntryPoint { val mediaSource: MediaSource, val thumbnailSource: MediaSource?, val canShowInfo: Boolean, + val galleryItems: List = emptyList(), ) : NodeInputs sealed interface MediaViewerMode : Parcelable { diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt index 898a29aa7a9..05d5dd40229 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt @@ -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 { 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? = 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, + ) } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaItemsFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaItemsFactory.kt index d2f78a7249e..951603a9dd4 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaItemsFactory.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaItemsFactory.kt @@ -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>(replay = 1) private val lock = Mutex() - private val diffCache = MutableListDiffCache() - private val diffCacheUpdater = DiffCacheUpdater( - 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>() + private var previousTimelineItems: List = emptyList() val timelineItems: Flow> = _timelineItems.distinctUntilChanged() @@ -51,39 +39,43 @@ class TimelineMediaItemsFactory( timelineItems: List, ) = withContext(dispatchers.computation) { lock.withLock { - diffCacheUpdater.updateWith(timelineItems) - buildAndEmitTimelineItemStates(timelineItems) - } - } - - private suspend fun buildAndEmitTimelineItemStates( - timelineItems: List, - ) { - val newTimelineItemStates = ArrayList() - 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() + 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, index)) { + 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, - 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, + currentIndex: Int, + ): 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 } } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSource.kt new file mode 100644 index 00000000000..7cb2913926f --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSource.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.viewer + +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.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.GalleryItemData +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 +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 = 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, + mediaInfo: MediaInfo, + mode: MediaViewerEntryPoint.MediaViewerMode, + ): GalleryMediaGalleryDataSource { + val imageAndVideoItems = mutableListOf() + val fileItems = mutableListOf() + + galleryItems.forEachIndexed { index, galleryItem -> + val itemMediaInfo = MediaInfo( + filename = galleryItem.filename, + fileSize = null, + caption = mediaInfo.caption, + mimeType = galleryItem.mimeType, + formattedFileSize = "", + fileExtension = galleryItem.filename.substringAfterLast('.', ""), + senderId = mediaInfo.senderId, + senderName = mediaInfo.senderName, + senderAvatar = mediaInfo.senderAvatar, + dateSent = mediaInfo.dateSent, + dateSentFull = mediaInfo.dateSentFull, + waveform = null, + duration = null, + ) + val id = UniqueId("${eventId?.value ?: "gallery"}_$index") + val mediaItem: MediaItem.Event = when { + galleryItem.isVideo -> MediaItem.Video( + id = id, + eventId = eventId, + mediaInfo = itemMediaInfo, + mediaSource = galleryItem.mediaSource, + thumbnailSource = galleryItem.thumbnailSource, + ) + galleryItem.isAudio -> MediaItem.Audio( + id = id, + eventId = eventId, + mediaInfo = itemMediaInfo, + mediaSource = galleryItem.mediaSource, + ) + galleryItem.isFile -> MediaItem.File( + id = id, + eventId = eventId, + mediaInfo = itemMediaInfo, + mediaSource = galleryItem.mediaSource, + ) + else -> 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(), + ) + ) + } + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt index e594daeb418..f9b6ff80d3d 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt @@ -76,6 +76,13 @@ class MediaViewerNode( private val mediaGallerySource = if (inputs.mode == MediaViewerEntryPoint.MediaViewerMode.SingleMedia) { SingleMediaGalleryDataSource.createFrom(inputs) + } else if (inputs.galleryItems.isNotEmpty()) { + GalleryMediaGalleryDataSource.createFrom( + eventId = inputs.eventId, + galleryItems = inputs.galleryItems, + mediaInfo = inputs.mediaInfo, + mode = inputs.mode, + ) } else { val eventId = inputs.eventId if (eventId == null) { diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt index 296f20ef4cf..09003445536 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt @@ -290,4 +290,21 @@ class MediaViewerPresenter( CommonStrings.error_unknown } } + + private fun searchIndex(data: List, eventId: EventId?): Int { + if (eventId == null) { + return 0 + } + val mediaSource = inputs.mediaSource + val exactMatch = data.indexOfFirst { + val pageData = it as? MediaViewerPageData.MediaViewerData + pageData?.eventId == eventId && pageData.mediaSource == mediaSource + } + if (exactMatch >= 0) { + return exactMatch + } + return data.indexOfFirst { + (it as? MediaViewerPageData.MediaViewerData)?.eventId == eventId + }.coerceAtLeast(0) + } } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/PagerKeysHandler.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/PagerKeysHandler.kt index a2234143aff..b8de20a41dd 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/PagerKeysHandler.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/PagerKeysHandler.kt @@ -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() - .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()) } } diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultEventItemFactoryTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultEventItemFactoryTest.kt index ab3ffa98c75..707cb088ddb 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultEventItemFactoryTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultEventItemFactoryTest.kt @@ -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, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt index 64583bd1d44..2f3fa9b0b75 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt @@ -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 } } From 2ab6f9683268da7bfb89898d05500280e5b53cd8 Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Tue, 26 May 2026 16:09:15 +0000 Subject: [PATCH 02/52] Address review --- .../preview/AttachmentsPreviewPresenter.kt | 42 +++++++++++-------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt index 7298e5892e9..aaeb03e9700 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt @@ -100,7 +100,7 @@ class AttachmentsPreviewPresenter( var appliedImageEdits by remember { mutableStateOf(AttachmentImageEdits()) } var isApplyingImageEdits by remember { mutableStateOf(false) } var displayImageEditError by remember { mutableStateOf(false) } - var editedTempFile by remember { mutableStateOf(null) } + var editedTempFiles by remember { mutableStateOf>(emptyMap()) } val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false) val textEditorState by rememberUpdatedState( @@ -158,13 +158,21 @@ var displayFileTooLargeError by remember { mutableStateOf(false) } val maxUploadSize = mediaOptimizationSelectorState.maxUploadSize.dataOrNull() LaunchedEffect(maxUploadSize) { - val isImageFile = firstMediaAttachment.localMedia.info.isImageAttachment() - val isVideoFile = firstMediaAttachment.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 = firstMediaAttachment.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 + } + } + } } } } @@ -210,8 +218,8 @@ var displayFileTooLargeError by remember { mutableStateOf(false) } val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder) .takeIf { it.isNotEmpty() } - val editedTempFileToDelete = editedTempFile - editedTempFile = null + 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) { @@ -224,7 +232,7 @@ var displayFileTooLargeError by remember { mutableStateOf(false) } // Clean up the pre-processed media after it's been sent mediaSender.cleanUp() - editedTempFileToDelete?.safeDelete() + editedTempFilesToDelete.values.forEach { it.safeDelete() } } } } @@ -244,7 +252,7 @@ var displayFileTooLargeError by remember { mutableStateOf(false) } dismiss( attachments = currentAttachments, sendActionState = sendActionState, - editedTempFile = editedTempFile, + editedTempFiles = editedTempFiles, ) } AttachmentsPreviewEvent.CancelAndClearSendState -> { @@ -297,8 +305,8 @@ var displayFileTooLargeError by remember { mutableStateOf(false) } AttachmentsPreviewEvent.ApplyImageEdits -> { val pendingState = imageEditorState ?: return if (!pendingState.edits.hasChanges) { - editedTempFile?.safeDelete() - editedTempFile = null + editedTempFiles[currentIndex]?.safeDelete() + editedTempFiles = editedTempFiles - currentIndex appliedImageEdits = pendingState.edits currentAttachment = Attachment.Media(originalLocalMedia) currentAttachments = currentAttachments.toMutableList().also { @@ -319,8 +327,8 @@ var displayFileTooLargeError by remember { mutableStateOf(false) } } result.fold( onSuccess = { editedMedia -> - editedTempFile?.safeDelete() - editedTempFile = editedMedia.file + editedTempFiles[currentIndex]?.safeDelete() + editedTempFiles = editedTempFiles + (currentIndex to editedMedia.file) appliedImageEdits = pendingState.edits currentAttachment = Attachment.Media(editedMedia.localMedia) currentAttachments = currentAttachments.toMutableList().also { @@ -400,7 +408,7 @@ var displayFileTooLargeError by remember { mutableStateOf(false) } private fun dismiss( attachments: List, sendActionState: MutableState, - editedTempFile: File? = null, + editedTempFiles: Map = emptyMap(), ) { for (attachment in attachments) { when (attachment) { @@ -411,7 +419,7 @@ var displayFileTooLargeError by remember { mutableStateOf(false) } } val uploadInfos = (sendActionState.value as? SendActionState.Sending.ReadyToUpload)?.mediaInfos uploadInfos?.forEach { cleanUp(it) } - editedTempFile?.safeDelete() + editedTempFiles.values.forEach { it.safeDelete() } sendActionState.value = SendActionState.Done onDoneListener() } From c5f0e4b78f4f948a0b117bc82a66c9659a506ddb Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:02:07 +0300 Subject: [PATCH 03/52] Remove unused function --- .../preview/AttachmentsPreviewView.kt | 23 ------------------- 1 file changed, 23 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt index 233f4156e15..c2a945a0e7f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt @@ -618,29 +618,6 @@ fun VideoCompressionPreset.subtitle(): String { ) } -@Composable -private fun GalleryBadge(count: Int, modifier: Modifier = Modifier) { - val contentDescription = pluralStringResource(R.plurals.common_attachments_count, count, count) - Box( - modifier = modifier - .padding(12.dp) - .background( - color = ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.8f), - shape = RoundedCornerShape(8.dp), - ) - .padding(horizontal = 8.dp, vertical = 4.dp) - .semantics { - this.contentDescription = contentDescription - }, - ) { - Text( - text = "$count", - style = ElementTheme.typography.fontBodySmMedium, - color = ElementTheme.colors.textPrimary, - ) - } -} - @Composable internal fun GalleryCarouselPill( currentIndex: Int, From 6813a0c9f09e66aa1e110fda002b42eaabc40f6c Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:03:55 +0300 Subject: [PATCH 04/52] Fix indent --- .../impl/attachments/preview/AttachmentsPreviewPresenter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt index aaeb03e9700..4453756292d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt @@ -126,7 +126,7 @@ class AttachmentsPreviewPresenter( val observableSendState = snapshotFlow { sendActionState.value } -var displayFileTooLargeError by remember { mutableStateOf(false) } + var displayFileTooLargeError by remember { mutableStateOf(false) } LaunchedEffect(attachments) { currentIndex = 0 From 4715549073ad6110e27c3506b8c659129705f6b4 Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Mon, 1 Jun 2026 11:21:52 +0000 Subject: [PATCH 05/52] Add "Gallery" message prefix --- .../impl/DefaultPinnedMessagesBannerFormatter.kt | 2 +- libraries/ui-strings/src/main/res/values/temporary.xml | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 libraries/ui-strings/src/main/res/values/temporary.xml diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt index b4b95ff1da7..1311c524e4f 100644 --- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt @@ -106,7 +106,7 @@ class DefaultPinnedMessagesBannerFormatter( messageType.body } is GalleryMessageType -> { - messageType.body.prefixWith(CommonStrings.common_image) + messageType.body.prefixWith(CommonStrings.common_gallery) } is NoticeMessageType -> { messageType.body diff --git a/libraries/ui-strings/src/main/res/values/temporary.xml b/libraries/ui-strings/src/main/res/values/temporary.xml new file mode 100644 index 00000000000..1e7cc7476d0 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values/temporary.xml @@ -0,0 +1,9 @@ + + + Gallery + From bf5d36bfab3b0ae5e5413aa245494971abe794ba Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:08:39 +0000 Subject: [PATCH 06/52] Support galleries for image editing --- .../preview/AttachmentsPreviewPresenter.kt | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt index 4453756292d..ffa92189bc8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt @@ -152,8 +152,11 @@ class AttachmentsPreviewPresenter( } } - 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() @@ -270,13 +273,14 @@ class AttachmentsPreviewPresenter( } } 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, + localMedia = currentLocalMedia, edits = appliedImageEdits, previewDebug = false, ) @@ -308,7 +312,7 @@ class AttachmentsPreviewPresenter( editedTempFiles[currentIndex]?.safeDelete() editedTempFiles = editedTempFiles - currentIndex appliedImageEdits = pendingState.edits - currentAttachment = Attachment.Media(originalLocalMedia) + currentAttachment = currentAttachments[currentIndex] currentAttachments = currentAttachments.toMutableList().also { it[currentIndex] = currentAttachment }.toImmutableList() @@ -321,7 +325,7 @@ class AttachmentsPreviewPresenter( coroutineScope.launch { val result = withContext(dispatchers.io) { attachmentImageEditor.exportEdits( - localMedia = originalLocalMedia, + localMedia = pendingState.localMedia, edits = pendingState.edits, ) } From 0f094a17c11ee9b6bfeccc12999b7f438dcd1ac4 Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:08:39 +0000 Subject: [PATCH 07/52] Fix tapping on a tile opens the last item --- .../mediaviewer/impl/viewer/MediaViewerDataSource.kt | 7 +++++-- .../mediaviewer/impl/viewer/MediaViewerPresenter.kt | 6 +++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt index 6feb3c7d535..d7685796daa 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt @@ -98,9 +98,12 @@ class MediaViewerDataSource( /** * Find the index of the page corresponding to the given eventId, or null if not found. */ - fun findEventIndex(eventId: EventId?): Int? { + fun findEventIndex(eventId: EventId?, mediaSource: MediaSource? = null): Int? { if (eventId == null) return null - return dataFlow.value.indexOfFirst { (it as? MediaViewerPageData.MediaViewerData)?.eventId == eventId }.takeIf { it >= 0 } + return dataFlow.value.indexOfFirst { + val pageData = it as? MediaViewerPageData.MediaViewerData + pageData?.eventId == eventId && (mediaSource == null || pageData.mediaSource == mediaSource) + }.takeIf { it >= 0 } } @VisibleForTesting diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt index 09003445536..053a4e28be9 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt @@ -70,17 +70,17 @@ class MediaViewerPresenter( @Composable override fun present(): MediaViewerState { val coroutineScope = rememberCoroutineScope() - val currentIndex = remember { mutableIntStateOf(dataSource.findEventIndex(inputs.eventId) ?: 0) } + val currentIndex = remember { mutableIntStateOf(dataSource.findEventIndex(inputs.eventId, inputs.mediaSource) ?: 0) } 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 + currentIndex.intValue = dataSource.findEventIndex(inputs.eventId, inputs.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(inputs.eventId, inputs.mediaSource) ?: 0 } value = new } From d9a8e13662b548a1d57723b8abf4074cfd8a53b2 Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:08:39 +0000 Subject: [PATCH 08/52] Fix overflow count --- .../impl/timeline/components/event/TimelineItemGalleryView.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt index 02d550d00f2..89fe302d07f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt @@ -78,8 +78,8 @@ fun TimelineItemGalleryView( if (content.items.isEmpty()) return val totalItems = content.items.size - val showOverflow = totalItems > MAX_TILES - 1 - val overflowCount = totalItems - (MAX_TILES - 1) + val showOverflow = totalItems > MAX_TILES + val overflowCount = totalItems - MAX_TILES Column(modifier = modifier) { val containerModifier = Modifier.clip(RoundedCornerShape(GROUP_CORNER_RADIUS)) From ef41ca70ad6c492b6d34b1b7c4c80c13a4cb5c0f Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:08:39 +0000 Subject: [PATCH 09/52] Add caption editing to gallery messages --- .../features/messages/impl/MessagesPresenter.kt | 5 +++-- .../messages/impl/actionlist/ActionListPresenter.kt | 12 +++++++++--- .../timeline/model/event/TimelineItemEventContent.kt | 12 ++++++++++++ 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index d3737f5fae3..dda31256f11 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -51,6 +51,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt 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 +534,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 +607,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)) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt index f9d97f6d9d7..080a3adccbe 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt @@ -28,13 +28,16 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser 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.TimelineItemEventContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent 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 import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.canBeCopied +import io.element.android.features.messages.impl.timeline.model.event.captionOrNull 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.libraries.architecture.Presenter @@ -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) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt index 967e7641675..5aa99fd4eb9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt @@ -103,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 From d353cc1ebf630ab07cf650b09807521b632f167a Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:08:39 +0000 Subject: [PATCH 10/52] Use the new gallery string for prefix --- .../impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt | 2 +- .../eventformatter/impl/DefaultRoomLatestEventFormatter.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt index 218da22fd75..0e2e654c4bd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt @@ -58,7 +58,7 @@ 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_image) + is TimelineItemGalleryContent -> context.getString(CommonStrings.common_gallery) is TimelineItemAttachmentsContent -> { val count = content.attachments.size val extensions = content.attachments.take(3).joinToString(", ") { it.fileExtension } diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt index c4997d2a38e..1c5cd729778 100644 --- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt @@ -169,7 +169,7 @@ class DefaultRoomLatestEventFormatter( messageType.body } is GalleryMessageType -> { - messageType.body.prefixWith(sp.getString(CommonStrings.common_image)) + messageType.body.prefixWith(sp.getString(CommonStrings.common_gallery)) } is NoticeMessageType -> { messageType.body From b0c110005fbb8ff78a4de6f2890b0607235a2c7d Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:08:39 +0000 Subject: [PATCH 11/52] Lock sending galleries behind a feature flag --- .../MessageComposerPresenter.kt | 21 +++++++++++++++++-- ...essageComposerPresenterSlashCommandTest.kt | 3 +++ .../MessageComposerPresenterTest.kt | 3 +++ .../libraries/featureflag/api/FeatureFlags.kt | 7 +++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 4b7f17dad24..5e9a4a9e5c5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -48,6 +48,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.UserId import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder @@ -132,6 +134,7 @@ class MessageComposerPresenter( private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, private val notificationConversationService: NotificationConversationService, private val slashCommandService: SlashCommandService, + private val featureFlagService: FeatureFlagService, ) : Presenter { @AssistedFactory interface Factory { @@ -175,6 +178,9 @@ class MessageComposerPresenter( canShareLocation.value = locationService.isServiceAvailable() } + val isSendGalleryMessagesEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.SendGalleryMessages) + .collectAsState(initial = true) + val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType -> handlePickedMedia(uri, mimeType) } @@ -184,6 +190,9 @@ class MessageComposerPresenter( val filesPicker = mediaPickerProvider.registerFileMultiPicker(AnyMimeTypes) { uris -> handlePickedMediaList(uris) } + val fileSinglePicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri, mimeType -> + handlePickedMedia(uri, mimeType) + } val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri -> handlePickedMedia(uri, MimeTypes.Jpeg) } @@ -291,11 +300,19 @@ class MessageComposerPresenter( MessageComposerEvent.DismissAttachmentMenu -> showAttachmentSourcePicker = false MessageComposerEvent.PickAttachmentSource.FromGallery -> localCoroutineScope.launch { showAttachmentSourcePicker = false - galleryMultiMediaPicker.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 diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt index 116a1cfb5de..053b6155eab 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt @@ -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.UserId import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder import io.element.android.libraries.matrix.api.permalink.PermalinkParser @@ -273,6 +274,7 @@ class MessageComposerPresenterSlashCommandTest { mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), isInThread: Boolean = false, slashCommandService: SlashCommandService = FakeSlashCommandService(), + featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), ) = MessageComposerPresenter( navigator = navigator, sessionCoroutineScope = this, @@ -311,6 +313,7 @@ class MessageComposerPresenterSlashCommandTest { mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, notificationConversationService = notificationConversationService, slashCommandService = slashCommandService, + featureFlagService = featureFlagService, ).apply { isTesting = true showTextFormatting = isRichTextEditorEnabled diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt index e8d106a80f0..8f74cc520c4 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt @@ -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 @@ -1536,6 +1537,7 @@ class MessageComposerPresenterTest { mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), isInThread: Boolean = false, slashCommandService: SlashCommandService = FakeSlashCommandService(), + featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), ) = MessageComposerPresenter( navigator = navigator, sessionCoroutineScope = this, @@ -1574,6 +1576,7 @@ class MessageComposerPresenterTest { mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, notificationConversationService = notificationConversationService, slashCommandService = slashCommandService, + featureFlagService = featureFlagService, ).apply { isTesting = true showTextFormatting = isRichTextEditorEnabled diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index 097df99800f..53c89b4f727 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -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, + ), } From 25d0ce22f1879592c3c125c1b0e7c5f0351ebc85 Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:10:10 +0000 Subject: [PATCH 12/52] Fix detekts --- .../features/messages/impl/MessagesFlowNode.kt | 2 +- .../features/messages/impl/MessagesPresenter.kt | 1 - .../features/messages/impl/MessagesView.kt | 4 ++-- .../preview/AttachmentsPreviewPresenter.kt | 6 ++---- .../preview/AttachmentsPreviewView.kt | 1 - .../messages/impl/timeline/TimelineView.kt | 2 +- .../timeline/components/TimelineItemEventRow.kt | 2 +- .../impl/timeline/components/TimelineItemRow.kt | 2 +- .../event/TimelineItemEventContentView.kt | 2 +- .../components/event/TimelineItemGalleryView.kt | 14 ++++++++------ .../event/TimelineItemContentMessageFactory.kt | 2 +- .../AttachmentsPreviewPresenterTest.kt | 4 ---- .../datasource/TimelineMediaItemsFactory.kt | 3 +-- .../viewer/GalleryMediaGalleryDataSource.kt | 2 -- .../mediaviewer/impl/viewer/MediaViewerNode.kt | 1 - .../impl/viewer/MediaViewerPresenter.kt | 17 ----------------- 16 files changed, 19 insertions(+), 46 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 392480475f8..5516cee22c0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -740,7 +740,7 @@ class MessagesFlowNode( isFile = galleryItem.isFile, ) }.reversed() - val mode = if (event.content.items.any { it.isVideo || (!it.isAudio && !it.isFile) }) { + val mode = if (event.content.items.any { it.isVideo || !it.isAudio && !it.isFile }) { MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(timelineMode) } else { MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index dda31256f11..1109b2c70dd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -47,7 +47,6 @@ 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 diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 853969a88c2..3f72f9ee20c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -136,7 +136,6 @@ fun MessagesView( onBackClick: () -> Unit, onRoomDetailsClick: () -> Unit, onEventContentClick: (isLive: Boolean, event: TimelineItem.Event) -> Boolean, - onGalleryItemClick: ((isLive: Boolean, event: TimelineItem.Event, index: Int) -> Boolean)? = null, onUserDataClick: (UserId) -> Unit, onLinkClick: (String, Boolean) -> Unit, onSendLocationClick: () -> Unit, @@ -145,6 +144,7 @@ fun MessagesView( onViewAllPinnedMessagesClick: () -> Unit, onThreadsListClick: () -> Unit, modifier: Modifier = Modifier, + onGalleryItemClick: ((isLive: Boolean, event: TimelineItem.Event, index: Int) -> Boolean)? = null, forceJumpToBottomVisibility: Boolean = false, knockRequestsBannerView: @Composable () -> Unit, ) { @@ -462,7 +462,6 @@ private fun ReinviteDialog(state: MessagesState) { private fun MessagesViewContent( state: MessagesState, onContentClick: (TimelineItem.Event) -> Unit, - onGalleryItemClick: ((TimelineItem.Event, Int) -> Unit)? = null, onUserDataClick: (MatrixUser) -> Unit, onLinkClick: (Link, Boolean) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit, @@ -476,6 +475,7 @@ private fun MessagesViewContent( forceJumpToBottomVisibility: Boolean, onSwipeToReply: (TimelineItem.Event) -> Unit, modifier: Modifier = Modifier, + onGalleryItemClick: ((TimelineItem.Event, Int) -> Unit)? = null, knockRequestsBannerView: @Composable () -> Unit, ) { Box( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt index 73236d76e2b..5779cc6ae5f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt @@ -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,7 +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.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 @@ -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, @@ -84,7 +83,6 @@ class AttachmentsPreviewPresenter( } private val mediaSender = mediaSenderFactory.create(timelineMode) - private val isGallery = attachments.size > 1 @Composable override fun present(): AttachmentsPreviewState { @@ -109,7 +107,7 @@ class AttachmentsPreviewPresenter( val ongoingSendAttachmentJob = remember { mutableStateOf(null) } - var currentIndex by remember { mutableStateOf(0) } + var currentIndex by remember { mutableIntStateOf(0) } var currentAttachments by remember { mutableStateOf(attachments) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt index 0c9706b5913..8c7c3570450 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt @@ -41,7 +41,6 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.heading diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index 706221f6532..9850b8ca76c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -97,7 +97,6 @@ fun TimelineView( onUserDataClick: (MatrixUser) -> Unit, onLinkClick: (Link) -> Unit, onContentClick: (TimelineItem.Event) -> Unit, - onGalleryItemClick: ((TimelineItem.Event, Int) -> Unit)? = null, onMessageLongClick: (TimelineItem.Event) -> Unit, onSwipeToReply: (TimelineItem.Event) -> Unit, onReactionClick: (emoji: String, TimelineItem.Event) -> Unit, @@ -105,6 +104,7 @@ fun TimelineView( onMoreReactionsClick: (TimelineItem.Event) -> Unit, onReadReceiptClick: (TimelineItem.Event) -> Unit, modifier: Modifier = Modifier, + onGalleryItemClick: ((TimelineItem.Event, Int) -> Unit)? = null, lazyListState: LazyListState = rememberLazyListState(), forceJumpToBottomVisibility: Boolean = false, nestedScrollConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index 4665db8dc6d..6b1991efa11 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -159,9 +159,9 @@ fun TimelineItemEventRow( onMoreReactionsClick: (eventId: TimelineItem.Event) -> Unit, onReadReceiptClick: (event: TimelineItem.Event) -> Unit, onSwipeToReply: () -> Unit, - onGalleryItemClick: ((Int) -> Unit)? = null, eventSink: (TimelineEvent.TimelineItemEvent) -> Unit, modifier: Modifier = Modifier, + onGalleryItemClick: ((Int) -> Unit)? = null, eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = { contentModifier, onContentLayoutChange -> // Only pass down a custom clickable lambda if the content can be clicked separately val onContentClick = onEventClick.takeUnless { event.isWholeContentClickable } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index 6c63268b367..d4a296ee8cf 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -64,7 +64,6 @@ internal fun TimelineItemRow( onLinkClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit, onContentClick: (TimelineItem.Event) -> Unit, - onGalleryItemClick: ((TimelineItem.Event, Int) -> Unit)? = null, onLongClick: (TimelineItem.Event) -> Unit, inReplyToClick: (EventId) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit, @@ -74,6 +73,7 @@ internal fun TimelineItemRow( onSwipeToReply: (TimelineItem.Event) -> Unit, eventSink: (TimelineEvent.TimelineItemEvent) -> Unit, modifier: Modifier = Modifier, + onGalleryItemClick: ((TimelineItem.Event, Int) -> Unit)? = null, eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = { event, contentModifier, onContentLayoutChange -> TimelineItemEventContentView( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt index e4c16aa080c..419ed56b037 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt @@ -42,13 +42,13 @@ fun TimelineItemEventContentView( content: TimelineItemEventContent, hideMediaContent: Boolean, onContentClick: (() -> Unit)?, - onGalleryItemClick: ((Int) -> Unit)? = null, onLongClick: (() -> Unit)?, onShowContentClick: () -> Unit, onLinkClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit, eventSink: (TimelineEvent.TimelineItemEvent) -> Unit, modifier: Modifier = Modifier, + onGalleryItemClick: ((Int) -> Unit)? = null, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {}, ) { val presenterFactories = LocalTimelineItemPresenterFactories.current diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt index 89fe302d07f..e912ed20daf 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt @@ -46,6 +46,8 @@ import io.element.android.features.messages.impl.timeline.components.layout.Cont 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 kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.bgSubtleTertiary @@ -98,21 +100,21 @@ fun TimelineItemGalleryView( } totalItems == 2 -> { TwoItemLayout( - items = content.items, + items = content.items.toImmutableList(), onContentClick = onContentClick, onLongClick = onLongClick, ) } totalItems == 3 -> { ThreeItemLayout( - items = content.items, + items = content.items.toImmutableList(), onContentClick = onContentClick, onLongClick = onLongClick, ) } totalItems >= 4 -> { FourPlusItemLayout( - items = content.items, + items = content.items.toImmutableList(), showOverflow = showOverflow, overflowCount = overflowCount, onContentClick = onContentClick, @@ -169,7 +171,7 @@ private fun SingleItemLayout( @Composable private fun TwoItemLayout( - items: List, + items: ImmutableList, onContentClick: ((Int) -> Unit)?, onLongClick: (() -> Unit)?, ) { @@ -194,7 +196,7 @@ private fun TwoItemLayout( @Composable private fun ThreeItemLayout( - items: List, + items: ImmutableList, onContentClick: ((Int) -> Unit)?, onLongClick: (() -> Unit)?, ) { @@ -242,7 +244,7 @@ private fun ThreeItemLayout( @Composable private fun FourPlusItemLayout( - items: List, + items: ImmutableList, showOverflow: Boolean, overflowCount: Int, onContentClick: ((Int) -> Unit)?, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt index 27166faa94f..6abd8fd1609 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt @@ -345,7 +345,7 @@ class TimelineItemContentMessageFactory( } val hasPreviews = galleryItems.any { it.thumbnailSource != null } val isMediaGallery = galleryItems.all { item -> - item.isVideo || (!item.isAudio && !item.isFile) + item.isVideo || !item.isAudio && !item.isFile } if (isMediaGallery && hasPreviews) { TimelineItemGalleryContent( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt index c6c6ca4045a..ae4ecf34fd4 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt @@ -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 @@ -863,7 +862,6 @@ class AttachmentsPreviewPresenterTest { localMedia: LocalMedia = aLocalMedia( uri = mockMediaUrl, ), - sendAsFile: Boolean = false, room: JoinedRoom = FakeJoinedRoom(), timelineMode: Timeline.Mode = Timeline.Mode.Live, permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(), @@ -893,7 +891,6 @@ class AttachmentsPreviewPresenterTest { ) ) }, - videoCompressionPresetSelector: VideoCompressionPresetSelector = VideoCompressionPresetSelector(), ): AttachmentsPreviewPresenter { return AttachmentsPreviewPresenter( attachments = persistentListOf(aMediaAttachment(localMedia)), @@ -914,7 +911,6 @@ class AttachmentsPreviewPresenterTest { sessionCoroutineScope = this, dispatchers = testCoroutineDispatchers(), mediaOptimizationSelectorPresenterFactory = mediaOptimizationSelectorPresenterFactory, - videoCompressionPresetSelector = videoCompressionPresetSelector, timelineMode = timelineMode, inReplyToEventId = null, mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaItemsFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaItemsFactory.kt index 951603a9dd4..1d262962694 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaItemsFactory.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaItemsFactory.kt @@ -44,7 +44,7 @@ class TimelineMediaItemsFactory( when (val currentTimelineItem = timelineItems[index]) { is MatrixTimelineItem.Event -> { val cachedItems = cache[currentTimelineItem.uniqueId] - val items = if (cachedItems != null && isItemUnchanged(currentTimelineItem, previousTimelineItems, index)) { + val items = if (cachedItems != null && isItemUnchanged(currentTimelineItem, previousTimelineItems)) { cachedItems } else { eventItemFactory.create(currentTimelineItem).also { newItems -> @@ -69,7 +69,6 @@ class TimelineMediaItemsFactory( private fun isItemUnchanged( currentTimelineItem: MatrixTimelineItem.Event, previousItems: List, - currentIndex: Int, ): Boolean { if (previousItems.isEmpty()) return false val previousIndex = previousItems.indexOfFirst { it is MatrixTimelineItem.Event && it.uniqueId == currentTimelineItem.uniqueId } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSource.kt index 7cb2913926f..569531fc56e 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSource.kt @@ -14,7 +14,6 @@ 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.GalleryItemData 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 import io.element.android.libraries.mediaviewer.impl.model.MediaItem @@ -37,7 +36,6 @@ class GalleryMediaGalleryDataSource( eventId: EventId?, galleryItems: List, mediaInfo: MediaInfo, - mode: MediaViewerEntryPoint.MediaViewerMode, ): GalleryMediaGalleryDataSource { val imageAndVideoItems = mutableListOf() val fileItems = mutableListOf() diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt index f9b6ff80d3d..c571497456f 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt @@ -81,7 +81,6 @@ class MediaViewerNode( eventId = inputs.eventId, galleryItems = inputs.galleryItems, mediaInfo = inputs.mediaInfo, - mode = inputs.mode, ) } else { val eventId = inputs.eventId diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt index 9af3f4e646c..2c17b88f54d 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt @@ -291,21 +291,4 @@ class MediaViewerPresenter( CommonStrings.error_unknown } } - - private fun searchIndex(data: List, eventId: EventId?): Int { - if (eventId == null) { - return 0 - } - val mediaSource = inputs.mediaSource - val exactMatch = data.indexOfFirst { - val pageData = it as? MediaViewerPageData.MediaViewerData - pageData?.eventId == eventId && pageData.mediaSource == mediaSource - } - if (exactMatch >= 0) { - return exactMatch - } - return data.indexOfFirst { - (it as? MediaViewerPageData.MediaViewerData)?.eventId == eventId - }.coerceAtLeast(0) - } } From 3cac0537344d8793754c946ea0bc804e73c4a54f Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:54:11 +0000 Subject: [PATCH 13/52] Fix --- .../impl/attachments/preview/AttachmentsPreviewPresenter.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt index 5779cc6ae5f..498210ae395 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt @@ -364,6 +364,7 @@ class AttachmentsPreviewPresenter( } is AttachmentsPreviewEvent.SetCurrentCarouselIndex -> { currentIndex = event.index + appliedImageEdits = AttachmentImageEdits() } } } From 6a1ee7d5f9d8b64cdf60c008127e191ba4e0c566 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 23 Jun 2026 14:11:04 +0200 Subject: [PATCH 14/52] Ensure image edition is saved when navigating --- .../preview/AttachmentsPreviewPresenter.kt | 43 ++++++++++++------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt index 498210ae395..ab59091c576 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt @@ -82,6 +82,11 @@ class AttachmentsPreviewPresenter( ): AttachmentsPreviewPresenter } + data class AttachmentAndEdits( + val attachment: Attachment, + val edits: AttachmentImageEdits, + ) + private val mediaSender = mediaSenderFactory.create(timelineMode) @Composable @@ -95,7 +100,6 @@ class AttachmentsPreviewPresenter( var currentAttachment by remember { mutableStateOf(attachments.first()) } var canEditImage by remember { mutableStateOf(originalLocalMedia.info.canEditImage()) } var imageEditorState by remember { mutableStateOf(null) } - var appliedImageEdits by remember { mutableStateOf(AttachmentImageEdits()) } var isApplyingImageEdits by remember { mutableStateOf(false) } var displayImageEditError by remember { mutableStateOf(false) } var editedTempFiles by remember { mutableStateOf>(emptyMap()) } @@ -109,7 +113,13 @@ class AttachmentsPreviewPresenter( var currentIndex by remember { mutableIntStateOf(0) } - var currentAttachments by remember { mutableStateOf(attachments) } + var attachmentsAndEdits by remember { + mutableStateOf( + attachments.map { + AttachmentAndEdits(it, AttachmentImageEdits()) + } + ) + } var preprocessMediaJob by remember { mutableStateOf(null) } @@ -141,7 +151,7 @@ class AttachmentsPreviewPresenter( ) preprocessMediaJob = coroutineScope.launch(dispatchers.io) { preProcessAttachments( - attachments = currentAttachments, + attachments = attachmentsAndEdits.map { it.attachment }, mediaOptimizationConfig = config, displayProgress = false, sendActionState = sendActionState, @@ -199,7 +209,7 @@ class AttachmentsPreviewPresenter( ) preprocessMediaJob = coroutineScope.launch(dispatchers.io) { preProcessAttachments( - attachments = currentAttachments, + attachments = attachmentsAndEdits.map { it.attachment }, mediaOptimizationConfig = config, displayProgress = true, sendActionState = sendActionState, @@ -251,7 +261,7 @@ class AttachmentsPreviewPresenter( // Dismiss the screen dismiss( - attachments = currentAttachments, + attachments = attachmentsAndEdits.map { it.attachment }, sendActionState = sendActionState, editedTempFiles = editedTempFiles, ) @@ -279,7 +289,7 @@ class AttachmentsPreviewPresenter( resetPreparedMedia(sendActionState) imageEditorState = AttachmentImageEditorState( localMedia = currentLocalMedia, - edits = appliedImageEdits, + edits = attachmentsAndEdits.get(currentIndex).edits, previewDebug = false, ) } @@ -321,10 +331,12 @@ class AttachmentsPreviewPresenter( if (!pendingState.edits.hasChanges) { editedTempFiles[currentIndex]?.safeDelete() editedTempFiles = editedTempFiles - currentIndex - appliedImageEdits = pendingState.edits - currentAttachment = currentAttachments[currentIndex] - currentAttachments = currentAttachments.toMutableList().also { - it[currentIndex] = currentAttachment + currentAttachment = attachmentsAndEdits[currentIndex].attachment + attachmentsAndEdits = attachmentsAndEdits.toMutableList().also { + it[currentIndex] = AttachmentAndEdits( + currentAttachment, + pendingState.edits, + ) }.toImmutableList() imageEditorState = null resetPreparedMedia(sendActionState) @@ -343,10 +355,12 @@ class AttachmentsPreviewPresenter( onSuccess = { editedMedia -> editedTempFiles[currentIndex]?.safeDelete() editedTempFiles = editedTempFiles + (currentIndex to editedMedia.file) - appliedImageEdits = pendingState.edits currentAttachment = Attachment.Media(editedMedia.localMedia) - currentAttachments = currentAttachments.toMutableList().also { - it[currentIndex] = currentAttachment + attachmentsAndEdits = attachmentsAndEdits.toMutableList().also { + it[currentIndex] = AttachmentAndEdits( + currentAttachment, + pendingState.edits, + ) }.toImmutableList() imageEditorState = null resetPreparedMedia(sendActionState) @@ -364,13 +378,12 @@ class AttachmentsPreviewPresenter( } is AttachmentsPreviewEvent.SetCurrentCarouselIndex -> { currentIndex = event.index - appliedImageEdits = AttachmentImageEdits() } } } return AttachmentsPreviewState( - attachments = currentAttachments, + attachments = attachmentsAndEdits.map { it.attachment }.toImmutableList(), imageEditorState = imageEditorState, canEditImage = canEditImage, isApplyingImageEdits = isApplyingImageEdits, From d8bcd7f1a7e4594527970daab2a573abc484e14e Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 23 Jun 2026 14:42:17 +0200 Subject: [PATCH 15/52] Fix sending media broken on edited gallery. --- .../preview/AttachmentsPreviewPresenter.kt | 15 ++++++ .../AttachmentsPreviewPresenterTest.kt | 50 ++++++++++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt index ab59091c576..3eedf1d2eed 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt @@ -215,6 +215,21 @@ class AttachmentsPreviewPresenter( 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 diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt index 239e3f25d81..c2ebfc95a8b 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt @@ -32,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 @@ -68,6 +69,7 @@ import io.element.android.tests.testutils.test import io.element.android.tests.testutils.testCoroutineDispatchers import io.mockk.every import io.mockk.mockk +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -856,10 +858,56 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { } } + @Test + fun `present - sending gallery after image edits restarts preprocessing`() = runTest { + val sendGalleryResult = + lambdaRecorder, String?, String?, EventId?, Result> { _, _, _, _ -> + 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 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"), + ) + ) + }, + ) + + 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() + } + } + private fun TestScope.createAttachmentsPreviewPresenter( localMedia: LocalMedia = aLocalMedia( uri = mockMediaUrl, ), + attachments: ImmutableList = persistentListOf(aMediaAttachment(localMedia)), room: JoinedRoom = FakeJoinedRoom(), timelineMode: Timeline.Mode = Timeline.Mode.Live, permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(), @@ -891,7 +939,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { }, ): AttachmentsPreviewPresenter { return AttachmentsPreviewPresenter( - attachments = persistentListOf(aMediaAttachment(localMedia)), + attachments = attachments, onDoneListener = onDoneListener, mediaSenderFactory = MediaSenderFactory { timelineMode -> DefaultMediaSender( From 0daf2f03e6a31dec6a50cf596a245e57db6b9a43 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 23 Jun 2026 15:04:27 +0200 Subject: [PATCH 16/52] Fix tests --- .../impl/attachments/AttachmentsPreviewPresenterTest.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt index c2ebfc95a8b..21b27acf37d 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt @@ -675,7 +675,6 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { val mediaPreProcessor = FakeMediaPreProcessor() val presenter = createAttachmentsPreviewPresenter( 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, @@ -818,7 +817,6 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { val mediaPreProcessor = FakeMediaPreProcessor() val presenter = createAttachmentsPreviewPresenter( 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, @@ -867,6 +865,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { 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 { } val presenter = createAttachmentsPreviewPresenter( room = FakeJoinedRoom( liveTimeline = FakeTimeline().apply { @@ -886,6 +885,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { ) ) }, + onDoneListener = OnDoneListener { onDoneListener() }, ) presenter.test { @@ -900,6 +900,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { consumeItemsUntilPredicate { it.sendActionState == SendActionState.Done } sendGalleryResult.assertions().isCalledOnce() + onDoneListener.assertions().isCalledOnce() } } From 510576a0cc84deef50828241ebdbcb374898374a Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 23 Jun 2026 15:06:10 +0200 Subject: [PATCH 17/52] Order imports --- .../features/messages/impl/actionlist/ActionListPresenter.kt | 4 ++-- .../impl/timeline/components/event/TimelineItemGalleryView.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt index 080a3adccbe..f17a841c52e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt @@ -27,8 +27,8 @@ 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.TimelineItemEventContent 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 @@ -37,9 +37,9 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.canBeCopied -import io.element.android.features.messages.impl.timeline.model.event.captionOrNull 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 diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt index e912ed20daf..6b224dfeb4d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt @@ -46,8 +46,6 @@ import io.element.android.features.messages.impl.timeline.components.layout.Cont 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 kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.bgSubtleTertiary @@ -56,6 +54,8 @@ 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 +import kotlinx.collections.immutable.toImmutableList import kotlin.time.Duration.Companion.seconds private const val MAX_TILES = 5 From 873e7c4cf1776981c2e271004c8b38601718a7e5 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 23 Jun 2026 15:10:46 +0200 Subject: [PATCH 18/52] Remove unused parameters. --- .../libraries/mediaupload/api/MediaUploadInfo.kt | 12 ++++++------ .../libraries/mediaupload/impl/DefaultMediaSender.kt | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt index 0f05bc315bc..3f3bf280a4d 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt @@ -33,38 +33,38 @@ fun MediaUploadInfo.allFiles(): List { ) } -fun MediaUploadInfo.toGalleryItemInfo(caption: String?, formattedCaption: String?): GalleryItemInfo { +fun MediaUploadInfo.toGalleryItemInfo(): GalleryItemInfo { return when (this) { is MediaUploadInfo.Image -> GalleryItemInfo.Image( file = file, imageInfo = imageInfo, thumbnailFile = thumbnailFile, - caption = caption, + caption = null, formattedCaption = null, ) is MediaUploadInfo.Video -> GalleryItemInfo.Video( file = file, videoInfo = videoInfo, thumbnailFile = thumbnailFile, - caption = caption, + caption = null, formattedCaption = null, ) is MediaUploadInfo.Audio -> GalleryItemInfo.Audio( file = file, audioInfo = audioInfo, - caption = caption, + caption = null, formattedCaption = null, ) is MediaUploadInfo.VoiceMessage -> GalleryItemInfo.Audio( file = file, audioInfo = audioInfo, - caption = caption, + caption = null, formattedCaption = null, ) is MediaUploadInfo.AnyFile -> GalleryItemInfo.MediaFile( file = file, fileInfo = fileInfo, - caption = caption, + caption = null, formattedCaption = null, ) } diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt index d7da5f01949..37f77d11f8b 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt @@ -177,7 +177,7 @@ class DefaultMediaSender( val galleryLogId = "gallery[${mediaUploadInfos.size} items]" Timber.d("Sending $galleryLogId") return getTimeline().flatMap { timeline -> - val galleryItems = mediaUploadInfos.map { it.toGalleryItemInfo(null, null) } + val galleryItems = mediaUploadInfos.map { it.toGalleryItemInfo() } timeline.sendGallery( items = galleryItems, caption = caption, From 2d2517098118db3819384960bddcf5c1558d4871 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 23 Jun 2026 15:54:23 +0200 Subject: [PATCH 19/52] Fix copyright header of new files. --- .../components/event/TimelineItemAttachmentsListView.kt | 3 +-- .../impl/timeline/components/event/TimelineItemGalleryView.kt | 3 +-- .../timeline/model/event/TimelineItemAttachmentsContent.kt | 3 +-- .../impl/timeline/model/event/TimelineItemGalleryContent.kt | 3 +-- .../android/libraries/matrix/api/media/GalleryItemInfo.kt | 3 +-- .../android/libraries/matrix/impl/media/FormattedBody.kt | 3 +-- .../libraries/matrix/impl/media/GalleryItemInfoMapper.kt | 3 +-- .../matrix/impl/media/GalleryMediaUploadHandlerImpl.kt | 3 +-- .../android/libraries/mediaviewer/api/GalleryItemData.kt | 3 +-- .../mediaviewer/impl/viewer/GalleryMediaGalleryDataSource.kt | 3 +-- 10 files changed, 10 insertions(+), 20 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt index a12104e504c..b51ba011345 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt @@ -1,6 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. + * 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. diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt index 6b224dfeb4d..fd811f5ff1e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt @@ -1,6 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. + * 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. diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAttachmentsContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAttachmentsContent.kt index 32264e52e0f..704e2d2fd14 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAttachmentsContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAttachmentsContent.kt @@ -1,6 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. + * 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. diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt index 1fb48763627..812789cb2f4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt @@ -1,6 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. + * 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. diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/GalleryItemInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/GalleryItemInfo.kt index 446febdbe52..99e588ed56e 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/GalleryItemInfo.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/GalleryItemInfo.kt @@ -1,6 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. + * 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. diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/FormattedBody.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/FormattedBody.kt index 015d1bce047..b431e82b264 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/FormattedBody.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/FormattedBody.kt @@ -1,6 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. + * 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. diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryItemInfoMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryItemInfoMapper.kt index fa46d19c6f4..efd8447fa05 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryItemInfoMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryItemInfoMapper.kt @@ -1,6 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. + * 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. diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryMediaUploadHandlerImpl.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryMediaUploadHandlerImpl.kt index ee3b1140d34..5bb01cdb564 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryMediaUploadHandlerImpl.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryMediaUploadHandlerImpl.kt @@ -1,6 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. + * 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. diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/GalleryItemData.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/GalleryItemData.kt index fd3ec5637a0..70a14a663c1 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/GalleryItemData.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/GalleryItemData.kt @@ -1,6 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. + * 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. diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSource.kt index 569531fc56e..f32af261e3d 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSource.kt @@ -1,6 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. + * 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. From 716688245cedcb47c6fdc145e5a03b848f858a05 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 23 Jun 2026 16:01:16 +0200 Subject: [PATCH 20/52] Fix Konsist test --- .../io/element/android/tests/konsist/KonsistClassNameTest.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt index 631def9f751..93b3200acc6 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt @@ -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'") } From b866f0f58df7762b934d93e1aac8157cfb7a7c67 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 23 Jun 2026 16:05:12 +0200 Subject: [PATCH 21/52] Extract new previews to a dedicated file. --- .../components/TimelineItemEventRow.kt | 249 ---------------- .../TimelineItemEventRowGalleryPreview.kt | 268 ++++++++++++++++++ 2 files changed, 268 insertions(+), 249 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowGalleryPreview.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index 6b1991efa11..6ad7c69fd3b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -843,255 +843,6 @@ internal fun TimelineItemEventRowPreview() = ElementPreview { } } -@PreviewsDayNight -@Composable -internal fun TimelineItemEventRowWithGalleryPreview() = ElementPreview { - Column { - sequenceOf(false, true).forEach { isMine -> - ATimelineItemEventRow( - event = aTimelineItemEvent( - isMine = isMine, - content = aTimelineItemGalleryContent( - caption = "My vacation photos", - items = listOf( - io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(), - io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(), - io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(), - io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(), - ), - ), - groupPosition = TimelineItemGroupPosition.Last, - ), - ) - } - } -} - -@PreviewsDayNight -@Composable -internal fun TimelineItemEventRowWithGalleryTwoItemsPreview() = ElementPreview { - Column { - sequenceOf(false, true).forEach { isMine -> - ATimelineItemEventRow( - event = aTimelineItemEvent( - isMine = isMine, - content = aTimelineItemGalleryContent( - items = listOf( - io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(), - io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(), - ), - ), - groupPosition = TimelineItemGroupPosition.Last, - ), - ) - } - } -} - -@PreviewsDayNight -@Composable -internal fun TimelineItemEventRowWithGalleryThreeItemsPreview() = ElementPreview { - Column { - sequenceOf(false, true).forEach { isMine -> - ATimelineItemEventRow( - event = aTimelineItemEvent( - isMine = isMine, - content = aTimelineItemGalleryContent( - caption = "Three photos", - items = listOf( - io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(), - io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(), - io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(), - ), - ), - groupPosition = TimelineItemGroupPosition.Last, - ), - ) - } - } -} - -@PreviewsDayNight -@Composable -internal fun TimelineItemEventRowWithGalleryManyItemsPreview() = ElementPreview { - Column { - sequenceOf(false, true).forEach { isMine -> - ATimelineItemEventRow( - event = aTimelineItemEvent( - isMine = isMine, - content = aTimelineItemGalleryContent( - caption = "Many photos", - items = (1..8).map { io.element.android.features.messages.impl.timeline.model.event.aGalleryItem() }, - ), - groupPosition = TimelineItemGroupPosition.Last, - ), - ) - } - } -} - -@PreviewsDayNight -@Composable -internal fun TimelineItemEventRowWithGalleryVideoItemsPreview() = ElementPreview { - Column { - sequenceOf(false, true).forEach { isMine -> - ATimelineItemEventRow( - event = aTimelineItemEvent( - isMine = isMine, - content = aTimelineItemGalleryContent( - caption = "Videos", - items = listOf( - io.element.android.features.messages.impl.timeline.model.event.aGalleryItem( - isVideo = true, - duration = kotlin.time.Duration.parse("PT1M30S") - ), - io.element.android.features.messages.impl.timeline.model.event.aGalleryItem( - isVideo = true, - duration = kotlin.time.Duration.parse("PT45S") - ), - io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(), - ), - ), - groupPosition = TimelineItemGroupPosition.Last, - ), - ) - } - } -} - -@PreviewsDayNight -@Composable -internal fun TimelineItemEventRowWithAttachmentsPreview() = ElementPreview { - Column { - sequenceOf(false, true).forEach { isMine -> - ATimelineItemEventRow( - event = aTimelineItemEvent( - isMine = isMine, - content = aTimelineItemAttachmentsContent( - caption = "Documents", - attachments = listOf( - io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem( - filename = "document.pdf", - fileExtension = "pdf", - ), - io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem( - filename = "presentation.pdf", - fileExtension = "pdf", - ), - io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem( - filename = "spreadsheet.xlsx", - fileExtension = "xlsx", - ), - ), - ), - groupPosition = TimelineItemGroupPosition.Last, - ), - ) - } - } -} - -@PreviewsDayNight -@Composable -internal fun TimelineItemEventRowWithAttachmentsImagesPreview() = ElementPreview { - Column { - sequenceOf(false, true).forEach { isMine -> - ATimelineItemEventRow( - event = aTimelineItemEvent( - isMine = isMine, - content = aTimelineItemAttachmentsContent( - caption = "Photos", - attachments = listOf( - io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem( - filename = "photo1.jpg", - fileExtension = "jpg", - hasThumbnail = true, - ), - io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem( - filename = "photo2.jpg", - fileExtension = "jpg", - hasThumbnail = true, - ), - io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem( - filename = "photo3.jpg", - fileExtension = "jpg", - hasThumbnail = true, - ), - ), - ), - groupPosition = TimelineItemGroupPosition.Last, - ), - ) - } - } -} - -@PreviewsDayNight -@Composable -internal fun TimelineItemEventRowWithAttachmentsVideosPreview() = ElementPreview { - Column { - sequenceOf(false, true).forEach { isMine -> - ATimelineItemEventRow( - event = aTimelineItemEvent( - isMine = isMine, - content = aTimelineItemAttachmentsContent( - caption = "Videos", - attachments = listOf( - io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem( - filename = "video1.mp4", - fileExtension = "mp4", - hasThumbnail = true, - fileSize = 150_000_000L, - formattedFileSize = "150MB", - ), - io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem( - filename = "video2.mov", - fileExtension = "mov", - hasThumbnail = true, - fileSize = 85_000_000L, - formattedFileSize = "85MB", - ), - ), - ), - groupPosition = TimelineItemGroupPosition.Last, - ), - ) - } - } -} - -@PreviewsDayNight -@Composable -internal fun TimelineItemEventRowWithAttachmentsAudioPreview() = ElementPreview { - Column { - sequenceOf(false, true).forEach { isMine -> - ATimelineItemEventRow( - event = aTimelineItemEvent( - isMine = isMine, - content = aTimelineItemAttachmentsContent( - caption = "Audio", - attachments = listOf( - io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem( - filename = "recording.mp3", - fileExtension = "mp3", - fileSize = 4_500_000L, - formattedFileSize = "4.5MB", - ), - io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem( - filename = "voice_message.m4a", - fileExtension = "m4a", - fileSize = 1_200_000L, - formattedFileSize = "1.2MB", - ), - ), - ), - groupPosition = TimelineItemGroupPosition.Last, - ), - ) - } - } -} - @PreviewsDayNight @Composable internal fun TimelineItemEventRowWithThreadSummaryPreview() = ElementPreview { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowGalleryPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowGalleryPreview.kt new file mode 100644 index 00000000000..44695ed0ad5 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowGalleryPreview.kt @@ -0,0 +1,268 @@ +/* + * 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 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.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 io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithGalleryPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = aTimelineItemGalleryContent( + caption = "My vacation photos", + items = listOf( + aGalleryItem(), + aGalleryItem(), + aGalleryItem(), + aGalleryItem(), + ), + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithGalleryTwoItemsPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = aTimelineItemGalleryContent( + items = listOf( + aGalleryItem(), + aGalleryItem(), + ), + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithGalleryThreeItemsPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = aTimelineItemGalleryContent( + caption = "Three photos", + items = listOf( + aGalleryItem(), + aGalleryItem(), + aGalleryItem(), + ), + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithGalleryManyItemsPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = aTimelineItemGalleryContent( + caption = "Many photos", + items = (1..8).map { aGalleryItem() }, + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithGalleryVideoItemsPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = aTimelineItemGalleryContent( + caption = "Videos", + items = listOf( + aGalleryItem( + isVideo = true, + duration = kotlin.time.Duration.parse("PT1M30S") + ), + aGalleryItem( + isVideo = true, + duration = kotlin.time.Duration.parse("PT45S") + ), + aGalleryItem(), + ), + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithAttachmentsPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = aTimelineItemAttachmentsContent( + caption = "Documents", + attachments = listOf( + anAttachmentItem( + filename = "document.pdf", + fileExtension = "pdf", + ), + anAttachmentItem( + filename = "presentation.pdf", + fileExtension = "pdf", + ), + anAttachmentItem( + filename = "spreadsheet.xlsx", + fileExtension = "xlsx", + ), + ), + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithAttachmentsImagesPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = 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, + ), + ), + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithAttachmentsVideosPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = 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", + ), + ), + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithAttachmentsAudioPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = 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", + ), + ), + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + } +} From a6f8230ab7d5d639a970a8383833c4677309d556 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 23 Jun 2026 16:55:18 +0200 Subject: [PATCH 22/52] Sync strings --- .../impl/attachments/preview/AttachmentsPreviewView.kt | 2 +- features/messages/impl/src/main/res/values/temporary.xml | 9 --------- features/startchat/impl/src/main/res/values/localazy.xml | 2 +- libraries/ui-strings/src/main/res/values/localazy.xml | 6 ++++++ libraries/ui-strings/src/main/res/values/temporary.xml | 9 --------- 5 files changed, 8 insertions(+), 20 deletions(-) delete mode 100644 features/messages/impl/src/main/res/values/temporary.xml delete mode 100644 libraries/ui-strings/src/main/res/values/temporary.xml diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt index 8c7c3570450..8de8a471668 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt @@ -639,7 +639,7 @@ internal fun GalleryCarouselPill( ) { Text( modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - text = stringResource(R.string.screen_media_upload_preview_count, currentIndex, totalCount), + text = stringResource(R.string.screen_media_upload_preview_item_count, currentIndex, totalCount), style = ElementTheme.typography.fontBodyMdMedium, color = ElementTheme.colors.textPrimary, ) diff --git a/features/messages/impl/src/main/res/values/temporary.xml b/features/messages/impl/src/main/res/values/temporary.xml deleted file mode 100644 index e78564dc8f3..00000000000 --- a/features/messages/impl/src/main/res/values/temporary.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - "%1$d item selected" - "%1$d items selected" - - "%1$d of %2$d" - Attachments - diff --git a/features/startchat/impl/src/main/res/values/localazy.xml b/features/startchat/impl/src/main/res/values/localazy.xml index 48b6449263c..dedfbfb5cc2 100644 --- a/features/startchat/impl/src/main/res/values/localazy.xml +++ b/features/startchat/impl/src/main/res/values/localazy.xml @@ -5,7 +5,7 @@ "An error occurred when trying to start a chat" "Join room by address" "Not a valid address" - "Enter…" + "Enter address…" "Matching room found" "Room not found" "e.g. #room-name:matrix.org" diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index ece51775e59..f477af40d87 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -5,6 +5,8 @@ "Address" "Avatar" "Minimise message text field" + "Decrease votes allowed per person" + "Increase votes allowed per person" "Delete" "%1$d digit entered" @@ -194,9 +196,11 @@ "Translate" "Try again" "Unpin" + "Video call" "View" "View in timeline" "View source" + "Voice call" "Yes" "Yes, try again" "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." @@ -261,6 +265,7 @@ Reason: %1$s." "File saved to Downloads" "Forward message" "Frequently used" + "Gallery" "GIF" "Group call in progress" "Image" @@ -488,6 +493,7 @@ Are you sure you want to continue?" "Options" "Remove %1$s" "Settings" + "Votes allowed per person" "Nobody is sharing their location" "Sharing live location" diff --git a/libraries/ui-strings/src/main/res/values/temporary.xml b/libraries/ui-strings/src/main/res/values/temporary.xml deleted file mode 100644 index 1e7cc7476d0..00000000000 --- a/libraries/ui-strings/src/main/res/values/temporary.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - Gallery - From 4e4723a2b13cae3d48b03445a8258ea5b88063b4 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 23 Jun 2026 17:26:31 +0200 Subject: [PATCH 23/52] Remove unused import --- .../messages/impl/timeline/components/TimelineItemEventRow.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index 6ad7c69fd3b..45d97905fc7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -78,8 +78,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent -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.aTimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation From 012933e8dfce2ee60ff8070a57680ccc8c225f5c Mon Sep 17 00:00:00 2001 From: ElementBot Date: Tue, 23 Jun 2026 16:57:37 +0000 Subject: [PATCH 24/52] Update screenshots --- ...nts.preview.imageeditor_AttachmentImageEditorView_2_en.png | 4 ++-- ...l.attachments.preview_AttachmentsPreviewGalleryView_en.png | 3 +++ ...rypto.identity_MessagesViewWithIdentityChange_Day_0_en.png | 4 ++-- ...rypto.identity_MessagesViewWithIdentityChange_Day_2_en.png | 4 ++-- ...pto.identity_MessagesViewWithIdentityChange_Night_1_en.png | 4 ++-- ...re.resolve_ResolveVerifiedUserSendFailureView_Day_2_en.png | 4 ++-- ....resolve_ResolveVerifiedUserSendFailureView_Night_1_en.png | 4 ++-- ...line.components.event_ATimelineItemEventRow_Night_0_en.png | 4 ++-- ...omponents.event_TimelineImageWithCaptionRow_Night_0_en.png | 4 ++-- ...nt_TimelineItemAttachmentsListViewWithCaption_Day_0_en.png | 3 +++ ..._TimelineItemAttachmentsListViewWithCaption_Night_0_en.png | 3 +++ ...ponents.event_TimelineItemAttachmentsListView_Day_0_en.png | 3 +++ ...nents.event_TimelineItemAttachmentsListView_Night_0_en.png | 3 +++ ...onents.event_TimelineItemGalleryViewFiveItems_Day_0_en.png | 3 +++ ...ents.event_TimelineItemGalleryViewFiveItems_Night_0_en.png | 3 +++ ...onents.event_TimelineItemGalleryViewManyItems_Day_0_en.png | 3 +++ ...ents.event_TimelineItemGalleryViewManyItems_Night_0_en.png | 3 +++ ...nents.event_TimelineItemGalleryViewSingleItem_Day_0_en.png | 3 +++ ...nts.event_TimelineItemGalleryViewSingleItem_Night_0_en.png | 3 +++ ...onents.event_TimelineItemGalleryViewThree1L2P_Day_0_en.png | 3 +++ ...ents.event_TimelineItemGalleryViewThree1L2P_Night_0_en.png | 3 +++ ...onents.event_TimelineItemGalleryViewThree2L1P_Day_0_en.png | 3 +++ ...ents.event_TimelineItemGalleryViewThree2L1P_Night_0_en.png | 3 +++ ...nents.event_TimelineItemGalleryViewThreeItems_Day_0_en.png | 3 +++ ...nts.event_TimelineItemGalleryViewThreeItems_Night_0_en.png | 3 +++ ...nts.event_TimelineItemGalleryViewTwoLandscape_Day_0_en.png | 3 +++ ...s.event_TimelineItemGalleryViewTwoLandscape_Night_0_en.png | 3 +++ ...ponents.event_TimelineItemGalleryViewTwoMixed_Day_0_en.png | 3 +++ ...nents.event_TimelineItemGalleryViewTwoMixed_Night_0_en.png | 3 +++ ...ents.event_TimelineItemGalleryViewTwoPortrait_Day_0_en.png | 3 +++ ...ts.event_TimelineItemGalleryViewTwoPortrait_Night_0_en.png | 3 +++ ...line.components.event_TimelineItemGalleryView_Day_0_en.png | 3 +++ ...ne.components.event_TimelineItemGalleryView_Night_0_en.png | 3 +++ ...meline.components.event_TimelineItemVideoView_Day_0_en.png | 4 ++-- ...meline.components.event_TimelineItemVideoView_Day_1_en.png | 4 ++-- ...meline.components.event_TimelineItemVideoView_Day_2_en.png | 4 ++-- ...meline.components.event_TimelineItemVideoView_Day_3_en.png | 4 ++-- ...line.components.event_TimelineItemVideoView_Night_0_en.png | 4 ++-- ...line.components.event_TimelineItemVideoView_Night_1_en.png | 4 ++-- ...line.components.event_TimelineItemVideoView_Night_2_en.png | 4 ++-- ...line.components.event_TimelineItemVideoView_Night_3_en.png | 4 ++-- ....components.event_TimelineVideoWithCaptionRow_Day_0_en.png | 4 ++-- ...omponents.event_TimelineVideoWithCaptionRow_Night_0_en.png | 4 ++-- ....components_TimelineItemEventRowDisambiguated_Day_0_en.png | 4 ++-- ...ents_TimelineItemEventRowWithAttachmentsAudio_Day_0_en.png | 3 +++ ...ts_TimelineItemEventRowWithAttachmentsAudio_Night_0_en.png | 3 +++ ...nts_TimelineItemEventRowWithAttachmentsImages_Day_0_en.png | 3 +++ ...s_TimelineItemEventRowWithAttachmentsImages_Night_0_en.png | 3 +++ ...nts_TimelineItemEventRowWithAttachmentsVideos_Day_0_en.png | 3 +++ ...s_TimelineItemEventRowWithAttachmentsVideos_Night_0_en.png | 3 +++ ...omponents_TimelineItemEventRowWithAttachments_Day_0_en.png | 3 +++ ...ponents_TimelineItemEventRowWithAttachments_Night_0_en.png | 3 +++ ...ents_TimelineItemEventRowWithGalleryManyItems_Day_0_en.png | 3 +++ ...ts_TimelineItemEventRowWithGalleryManyItems_Night_0_en.png | 3 +++ ...nts_TimelineItemEventRowWithGalleryThreeItems_Day_0_en.png | 3 +++ ...s_TimelineItemEventRowWithGalleryThreeItems_Night_0_en.png | 3 +++ ...nents_TimelineItemEventRowWithGalleryTwoItems_Day_0_en.png | 3 +++ ...nts_TimelineItemEventRowWithGalleryTwoItems_Night_0_en.png | 3 +++ ...nts_TimelineItemEventRowWithGalleryVideoItems_Day_0_en.png | 3 +++ ...s_TimelineItemEventRowWithGalleryVideoItems_Night_0_en.png | 3 +++ ...ne.components_TimelineItemEventRowWithGallery_Day_0_en.png | 3 +++ ....components_TimelineItemEventRowWithGallery_Night_0_en.png | 3 +++ ...ents_TimelineItemEventRowWithReplyInformative_Day_0_en.png | 4 ++-- ...ts_TimelineItemEventRowWithReplyInformative_Night_0_en.png | 4 ++-- ...components_TimelineItemEventRowWithReplyOther_Day_0_en.png | 4 ++-- ...mponents_TimelineItemEventRowWithReplyOther_Night_0_en.png | 4 ++-- ...line.components_TimelineItemEventRowWithReply_Day_0_en.png | 4 ++-- ...ine.components_TimelineItemEventRowWithReply_Day_10_en.png | 4 ++-- ...line.components_TimelineItemEventRowWithReply_Day_2_en.png | 4 ++-- ...line.components_TimelineItemEventRowWithReply_Day_4_en.png | 4 ++-- ...line.components_TimelineItemEventRowWithReply_Day_6_en.png | 4 ++-- ...line.components_TimelineItemEventRowWithReply_Day_8_en.png | 4 ++-- ...ne.components_TimelineItemEventRowWithReply_Night_0_en.png | 4 ++-- ...e.components_TimelineItemEventRowWithReply_Night_10_en.png | 4 ++-- ...ne.components_TimelineItemEventRowWithReply_Night_2_en.png | 4 ++-- ...ne.components_TimelineItemEventRowWithReply_Night_4_en.png | 4 ++-- ...ne.components_TimelineItemEventRowWithReply_Night_6_en.png | 4 ++-- ...ne.components_TimelineItemEventRowWithReply_Night_8_en.png | 4 ++-- .../features.messages.impl.timeline_TimelineView_Day_3_en.png | 4 ++-- ...eatures.messages.impl.timeline_TimelineView_Night_3_en.png | 4 ++-- ...references.impl.blockedusers_BlockedUsersView_Day_4_en.png | 4 ++-- ...ferences.impl.blockedusers_BlockedUsersView_Night_5_en.png | 4 ++-- ...chat.impl.joinbyaddress_JoinRoomByAddressView_Day_0_en.png | 4 ++-- ...at.impl.joinbyaddress_JoinRoomByAddressView_Night_0_en.png | 4 ++-- ...eme.components_HorizontalFloatingToolbarNoFab_Day_0_en.png | 4 ++-- ...em.theme.components_HorizontalFloatingToolbar_Day_0_en.png | 4 ++-- 86 files changed, 215 insertions(+), 86 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewGalleryView_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListViewWithCaption_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListViewWithCaption_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewFiveItems_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewFiveItems_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewManyItems_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewManyItems_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewSingleItem_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewSingleItem_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree1L2P_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree1L2P_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree2L1P_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree2L1P_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThreeItems_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThreeItems_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoLandscape_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoLandscape_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoMixed_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoMixed_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoPortrait_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoPortrait_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsAudio_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsAudio_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsImages_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsImages_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsVideos_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsVideos_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachments_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachments_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryManyItems_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryManyItems_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryThreeItems_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryThreeItems_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryTwoItems_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryTwoItems_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryVideoItems_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryVideoItems_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_0_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_2_en.png index 3192cf2a5e3..8edee63bc1e 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1885bbe8b0ea497ac20d7bcdd0c6a93ba0533e898378d35e1cafdc8c9924fd56 -size 279984 +oid sha256:de82970da241172b94f822f8792e6fc721dcfc7f0f67e3523962e253e9750884 +size 255933 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewGalleryView_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewGalleryView_en.png new file mode 100644 index 00000000000..bce4dd0969c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewGalleryView_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ed2c757e43dd8b25eb10acf32223168d10231ab2c9a9019ffe6b4eec6d656087 +size 343464 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_0_en.png index 693a36b7dd8..5f51b27f5e6 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3dbb8ef39b6a7a0c2f576d8bd101aa97c89da696f23e6d5836846494471c01a1 -size 55799 +oid sha256:3274314ab9e39c23a6b9d66adf80ac8bb7b364ce60a300f96582374a18bc821f +size 54590 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_2_en.png index 4185f46b51c..5289eaca63b 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:085963fcb425147d35a9ad947a7a3e4ba4712bd3d2f32e93099ff15cffe83b88 -size 64761 +oid sha256:65f9ee083a73669a0d3f9d88141a610ea53f3c39e89d8f706d414b04555b4764 +size 59321 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_1_en.png index 0f6de1b844e..bd3b0196d64 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:28512b0fda0b71b98eab03cee15c9d3163825b1a3545670cfa277ddd5434c87a -size 58417 +oid sha256:6f7f7415abbe51858f938e16ad03c6aab983fb3d5e2a53a028bae3c031446646 +size 67848 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.sendfailure.resolve_ResolveVerifiedUserSendFailureView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.sendfailure.resolve_ResolveVerifiedUserSendFailureView_Day_2_en.png index bd73e322abe..93622cedfe5 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.sendfailure.resolve_ResolveVerifiedUserSendFailureView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.sendfailure.resolve_ResolveVerifiedUserSendFailureView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81 -size 3657 +oid sha256:87193a7c9eb9d40234970e5f5be02e80f2b3ffe91e2c9ca6afd58b8bccdd5911 +size 55075 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.sendfailure.resolve_ResolveVerifiedUserSendFailureView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.sendfailure.resolve_ResolveVerifiedUserSendFailureView_Night_1_en.png index 0cf6c10c4e0..bd73e322abe 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.sendfailure.resolve_ResolveVerifiedUserSendFailureView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.sendfailure.resolve_ResolveVerifiedUserSendFailureView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:49f0bb16d3d57b59242e704e3dd4e8885c59721a42129837ff6a4f0d7d02de11 -size 54433 +oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81 +size 3657 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_ATimelineItemEventRow_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_ATimelineItemEventRow_Night_0_en.png index 3f12875a5be..06dd93b269e 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_ATimelineItemEventRow_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_ATimelineItemEventRow_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7076c9a44a433cd9eeca536365ae262d39b4ad99a8f854faadec272cb1b82031 -size 294992 +oid sha256:578a3707102c7754f607c3a14f94f32cafa30ee570db174386533c484cb4773c +size 295031 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineImageWithCaptionRow_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineImageWithCaptionRow_Night_0_en.png index 176ebfa36b8..90bee6c1177 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineImageWithCaptionRow_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineImageWithCaptionRow_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5a867dda82242d2b13bd4bc84a9f1c9b8b7b99c030e19a6cce39b2f828b517f3 -size 611488 +oid sha256:a709de55bc68cf12064ebdbde92c0a99bef3252958d7966540458d253e8c12c2 +size 611422 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListViewWithCaption_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListViewWithCaption_Day_0_en.png new file mode 100644 index 00000000000..bacf9f828c8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListViewWithCaption_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:40a46a63f4e27303a9ac0daec115296a880233b8e5fc717c4b75616d552810c3 +size 16170 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListViewWithCaption_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListViewWithCaption_Night_0_en.png new file mode 100644 index 00000000000..3d0945d8d63 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListViewWithCaption_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d4a8a086ac268a9d2dd45f738cffced918ed834e3892f42cf220d2b9745b15fe +size 15481 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Day_0_en.png new file mode 100644 index 00000000000..49f11c81e48 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d470f7df702048d8467c1daa4304b77adb641e5f481a9414ea5e9b72279eb1cf +size 29813 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Night_0_en.png new file mode 100644 index 00000000000..64e931c9d22 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:072d87f706f949dcb0c2600b51745709747af71c552aa6a264760a0c8a254b51 +size 29010 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewFiveItems_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewFiveItems_Day_0_en.png new file mode 100644 index 00000000000..0a7fb286f15 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewFiveItems_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c27d90e1fda73f23de789341fb7023501e7a9f1080a2b8f684f3f0e923a1e618 +size 314293 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewFiveItems_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewFiveItems_Night_0_en.png new file mode 100644 index 00000000000..c272bf13da9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewFiveItems_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e8e5cba748f3505bd3d34c74189c9bf1605a2cbb5fe534a3e2ce7d6df64065d2 +size 311163 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewManyItems_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewManyItems_Day_0_en.png new file mode 100644 index 00000000000..14393b4454e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewManyItems_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c1cb42c166ced625581718e7f0f11fe9a6a04a0fc9ccd907addd368c44884069 +size 309093 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewManyItems_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewManyItems_Night_0_en.png new file mode 100644 index 00000000000..146e3a92119 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewManyItems_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2c144e259fa322bd899a047ade06c7105a6d5f41732193baa9e1437067d7b47f +size 302873 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewSingleItem_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewSingleItem_Day_0_en.png new file mode 100644 index 00000000000..27e0ff2c995 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewSingleItem_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:85f7b816d7dcc1a825b0cdeadfb210809beea7142fe26a9e87ea4ef86015e2e3 +size 191317 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewSingleItem_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewSingleItem_Night_0_en.png new file mode 100644 index 00000000000..f94ac769f42 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewSingleItem_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2aafd40abe6880abadb610293cc06a38a1c7e995302019336e4ff33192d9cb4c +size 191195 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree1L2P_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree1L2P_Day_0_en.png new file mode 100644 index 00000000000..5bf7ad4ca9c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree1L2P_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f46ed316ce20fdec7c2a8ab834cc68c0db8f1cf5dab05a3835133274bdf74968 +size 372000 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree1L2P_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree1L2P_Night_0_en.png new file mode 100644 index 00000000000..c4f9b5ac917 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree1L2P_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f8c6615f0b06c1e1a358622e0598aa89bc7c7e2b5bdf7daad269c92434e124d +size 371473 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree2L1P_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree2L1P_Day_0_en.png new file mode 100644 index 00000000000..4d6c2f99147 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree2L1P_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a45bc7d128c477a017d3f64a1e7ae14b4de888873fce20e5b8f146871f52d08c +size 372020 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree2L1P_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree2L1P_Night_0_en.png new file mode 100644 index 00000000000..d2eafbba352 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree2L1P_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1e813d8530f1083a960a30cda348e7b68ec54b02f62773fd83788054d9b78a8c +size 371473 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThreeItems_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThreeItems_Day_0_en.png new file mode 100644 index 00000000000..e169c383a03 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThreeItems_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0f1d54f890b8e463ffb2a7b7a0b39f1ff9525fee74ef5884171e3d29bd47540f +size 349978 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThreeItems_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThreeItems_Night_0_en.png new file mode 100644 index 00000000000..71d008600cc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThreeItems_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:595438b4666929726f244b5f53fce3507bd6aa0fc87b10ea9706ae893a849a7f +size 348050 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoLandscape_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoLandscape_Day_0_en.png new file mode 100644 index 00000000000..1b523f9e941 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoLandscape_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1c675d5b6ea5bd2b5d1bc780cce41ecc179e4e2a2c1f0518069295d4d7b6d1d2 +size 380356 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoLandscape_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoLandscape_Night_0_en.png new file mode 100644 index 00000000000..01dad748943 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoLandscape_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b0d656a13c24c54c13e119da73f14e0523dce0ee76a9197783043ad5bca07b57 +size 380109 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoMixed_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoMixed_Day_0_en.png new file mode 100644 index 00000000000..1b523f9e941 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoMixed_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1c675d5b6ea5bd2b5d1bc780cce41ecc179e4e2a2c1f0518069295d4d7b6d1d2 +size 380356 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoMixed_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoMixed_Night_0_en.png new file mode 100644 index 00000000000..01dad748943 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoMixed_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b0d656a13c24c54c13e119da73f14e0523dce0ee76a9197783043ad5bca07b57 +size 380109 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoPortrait_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoPortrait_Day_0_en.png new file mode 100644 index 00000000000..1b523f9e941 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoPortrait_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1c675d5b6ea5bd2b5d1bc780cce41ecc179e4e2a2c1f0518069295d4d7b6d1d2 +size 380356 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoPortrait_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoPortrait_Night_0_en.png new file mode 100644 index 00000000000..01dad748943 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoPortrait_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b0d656a13c24c54c13e119da73f14e0523dce0ee76a9197783043ad5bca07b57 +size 380109 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_0_en.png new file mode 100644 index 00000000000..b06744a137f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a649c1213f7577e41c8fb847694a433fb8296008b020ce9c11b9f19e4daf2362 +size 366552 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_0_en.png new file mode 100644 index 00000000000..36cd635ba65 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f4a12a0fc4ad8f9d5b64a30e3d7ba8c678dfd8582c98b668d292594dba7b2742 +size 361947 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_0_en.png index 9238837c39e..95c3a57373a 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eb6b6a0c8d9891776097e0e5448c017dd9a943fe30b99735300d5107ff29ff47 -size 274673 +oid sha256:a1ded2aad1e00ff19022cfd44bf79cfd9f4ab8ba8f6c32b620e98ea418ad3155 +size 274099 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_1_en.png index 861f05e267e..d339251b45f 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b1e7bab4b587727f5d6f2da55db4bd2d4c35e0b12814a9ddac1f1ce176a687b2 -size 506341 +oid sha256:9a107f72769dba5206b02057fd16561f45bd915b9ef18c9057be5e3ba33aea41 +size 505699 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_2_en.png index c48e8c45b6b..2e218482449 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:57c4e5cd10840faa1ea17b9758b6b189eeb012f0a22a218d64c73d4c92ae3482 -size 383712 +oid sha256:c65b393e0c2ea85323a0ae3da77bd1075e1543ebd1ff8b8d64defd084929b4eb +size 383073 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_3_en.png index 9238837c39e..95c3a57373a 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eb6b6a0c8d9891776097e0e5448c017dd9a943fe30b99735300d5107ff29ff47 -size 274673 +oid sha256:a1ded2aad1e00ff19022cfd44bf79cfd9f4ab8ba8f6c32b620e98ea418ad3155 +size 274099 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_0_en.png index 9238837c39e..95c3a57373a 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eb6b6a0c8d9891776097e0e5448c017dd9a943fe30b99735300d5107ff29ff47 -size 274673 +oid sha256:a1ded2aad1e00ff19022cfd44bf79cfd9f4ab8ba8f6c32b620e98ea418ad3155 +size 274099 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_1_en.png index 861f05e267e..d339251b45f 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b1e7bab4b587727f5d6f2da55db4bd2d4c35e0b12814a9ddac1f1ce176a687b2 -size 506341 +oid sha256:9a107f72769dba5206b02057fd16561f45bd915b9ef18c9057be5e3ba33aea41 +size 505699 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_2_en.png index c48e8c45b6b..2e218482449 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:57c4e5cd10840faa1ea17b9758b6b189eeb012f0a22a218d64c73d4c92ae3482 -size 383712 +oid sha256:c65b393e0c2ea85323a0ae3da77bd1075e1543ebd1ff8b8d64defd084929b4eb +size 383073 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_3_en.png index 9238837c39e..95c3a57373a 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eb6b6a0c8d9891776097e0e5448c017dd9a943fe30b99735300d5107ff29ff47 -size 274673 +oid sha256:a1ded2aad1e00ff19022cfd44bf79cfd9f4ab8ba8f6c32b620e98ea418ad3155 +size 274099 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineVideoWithCaptionRow_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineVideoWithCaptionRow_Day_0_en.png index f792a1663ab..48894c2f21e 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineVideoWithCaptionRow_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineVideoWithCaptionRow_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e3bb005a88e9a4d2bb850478ee8ac2c4f31d32625bd6d2abf455fade461cd399 -size 607806 +oid sha256:85be731a87510f5825fd37c24f9057d86592b3d43ce93e2bcb511a20f80f6fca +size 606225 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineVideoWithCaptionRow_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineVideoWithCaptionRow_Night_0_en.png index 18319e718fb..aad5803218f 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineVideoWithCaptionRow_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineVideoWithCaptionRow_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4a2a57f5547f98b9ac2d8bf7195a1e4b49e03834e8866c6cc76660a6a3b16e74 -size 606823 +oid sha256:56fc2ff0f7f6f29f1696ac6c5300b04a1a00526fb95f380f4dd917033415c7aa +size 605173 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowDisambiguated_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowDisambiguated_Day_0_en.png index ab6392a424e..0435f55de6a 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowDisambiguated_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowDisambiguated_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:94f89a37549a3f198442db467bc7d8539f0b5649d97927bfe731f090d6dfb03d -size 379505 +oid sha256:ff839731834d7d0c94fa9a2c412014ddbaeaa053c5af1c8cbd00a3f3a583f364 +size 379511 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsAudio_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsAudio_Day_0_en.png new file mode 100644 index 00000000000..ee7a9300850 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsAudio_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e7f38d776f1d538e1cf5c8d56d8a0af99a18ca5d5b4bdae2eb7f912cd49ab227 +size 39146 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsAudio_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsAudio_Night_0_en.png new file mode 100644 index 00000000000..2b120273a88 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsAudio_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:28859edab7881602214e4c8d4206d64ae7aea00cd8b92a031a1e383f0be71653 +size 38672 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsImages_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsImages_Day_0_en.png new file mode 100644 index 00000000000..64378d8a5d9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsImages_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:af2f2c775304355a8540fb675008d3a3d0d562962035077a90c87be837ad5bd5 +size 103522 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsImages_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsImages_Night_0_en.png new file mode 100644 index 00000000000..328b0cb0889 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsImages_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a55b40b059220aeb73e62a645cf84fa4fd31c21aad0fa188c9e08b70215b1043 +size 102588 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsVideos_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsVideos_Day_0_en.png new file mode 100644 index 00000000000..d1c6a220dcb --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsVideos_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:40613ee16dae9b237a300d0d589045e8bbac8e61f842f232b78375aa27c18ebf +size 76264 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsVideos_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsVideos_Night_0_en.png new file mode 100644 index 00000000000..3ce2075468a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsVideos_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d19ef86a311234a3ce43778171f4efaef867513aa990f7bea19d3f8cff4db8a4 +size 75688 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachments_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachments_Day_0_en.png new file mode 100644 index 00000000000..68ad2c556a4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachments_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5291e32a1ff8bfb7f07c028c43733258d5db74537e956d4ace08346ae102a3c5 +size 48372 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachments_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachments_Night_0_en.png new file mode 100644 index 00000000000..15975510ea9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachments_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:70e3df3c1f51facbcb45764e346aed8081071bac01ecc6ed0b98f954d586c847 +size 47988 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryManyItems_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryManyItems_Day_0_en.png new file mode 100644 index 00000000000..fa21d48f59f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryManyItems_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c1fe348faa8c8adae205de19041a2e4fc3c1a82bc741c0fabf90df5da0aed206 +size 624607 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryManyItems_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryManyItems_Night_0_en.png new file mode 100644 index 00000000000..cc52531a723 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryManyItems_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ec0b2e34ba1d5c05ee11aaabfddf3e4627eeb671242a44aa1a9368c1c378bba1 +size 618352 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryThreeItems_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryThreeItems_Day_0_en.png new file mode 100644 index 00000000000..ea80da06509 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryThreeItems_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:50b831358465da4fd2a49de0e6029a33561bbf28530b281171690f8d25f98774 +size 744903 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryThreeItems_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryThreeItems_Night_0_en.png new file mode 100644 index 00000000000..4a107c909b7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryThreeItems_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f6c375796b1058179fd449279249217538b51688ac7bdf517ff431fe92f772b1 +size 743275 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryTwoItems_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryTwoItems_Day_0_en.png new file mode 100644 index 00000000000..b42850242be --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryTwoItems_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:36b99a288e36e90703a09459fbbf626c4bd08e25a98857cfb5cd75f2dbc4afa7 +size 764986 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryTwoItems_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryTwoItems_Night_0_en.png new file mode 100644 index 00000000000..65441d23c34 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryTwoItems_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d7d74b2eee87465a649e0459c42cd2db2038da51e18e8782b74db542926284fe +size 763800 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryVideoItems_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryVideoItems_Day_0_en.png new file mode 100644 index 00000000000..3adce582764 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryVideoItems_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b8d89b94128b47958bafdef99b338fc5bfd3586f73fdc2b665308fe0a9be8fd0 +size 719641 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryVideoItems_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryVideoItems_Night_0_en.png new file mode 100644 index 00000000000..349a26729ea --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryVideoItems_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ed41ea8189fb20c22a011694372d3acb6df8f4c81e01b1a03ec0eb56120934fe +size 706216 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_0_en.png new file mode 100644 index 00000000000..2deb3aeb0d1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:19708f245fe36dc51fffc9f628ba973d34dfa17ffb001ba763f21795b869c384 +size 723818 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_0_en.png new file mode 100644 index 00000000000..4f52d626f99 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4b26b7c4b25da7fed6311db1c79fc885ed7c237d48b305691afed0e4dc8c22cb +size 721899 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Day_0_en.png index 1f0fdabe6de..3ac59caeea4 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b8bb5a6de6601da8d7fe59f110a054577e2e25dae4bb449f60c04e7d7a1b9551 -size 365073 +oid sha256:1a94d5198e27338675c0e15df5874ba3272ee315b8d77291dc7ccc7c754ed1f4 +size 365082 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Night_0_en.png index 058a3992c39..29771235f00 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:00fe1e676086766a3eac3e4457eff52c989789fc5d8d0df2624c019ed25ce9af -size 363322 +oid sha256:60b9f2b920cd2a2a414805d1f0f77925232d9ae60bd607fa18d4395cda2dd73d +size 363291 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Day_0_en.png index 84d9d4e5c3f..94428abb56c 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7c609a633ab23eae5729ecb57a17a385bac65e81cbf0180c01e0847f1028039e -size 347890 +oid sha256:36d7cf2510d546d2b330b1d1b1674ce58df0558c547e443794a1e7b2222add87 +size 347897 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Night_0_en.png index bac23aab98f..22ff4965d10 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e30686c7c037ef102717e0316543faf4d3ed2671c21eca96a667150daab380d6 -size 346614 +oid sha256:787151f8f242a8794a804bf14831f6ca680814d617e85feb3652103e8a0f20cd +size 346565 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_0_en.png index 01d4dca84b8..67490ba7009 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ef2bf449671b652cb152b3baab92ab100eab77a54229ab49daeab156f2f484d0 -size 369724 +oid sha256:f4431e2d9f30123949ea5676ce60f642234adcf9b5a33f2acc3f80ebf6609a66 +size 369730 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_10_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_10_en.png index f0dfbc33218..1600afff711 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f7a6302320bd1e4258e03487539bf46b0b12781dfc8a22b3f9f267017a6146fa -size 354751 +oid sha256:273351cc9c533b2424859a0de00a8a3786628abc3a88175705c4205dedf7e9aa +size 354737 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_2_en.png index 8aa2cca147d..1b99468924a 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:02ecb903124273e24151ea2088f6b99144f7dcadba00a582b6210dd23835b5c9 -size 357743 +oid sha256:3a9509411777f342261cab58b92a022b3c9f8f0f7a9e4d0527120a91976575f0 +size 357733 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_4_en.png index a3327443584..14a40a56764 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:588967869cbd25ed8b82aff5618175660a8a9e45a5a861b1c7867cf1913f575b -size 364646 +oid sha256:a11044fe90227f1f316a1ade8587e545bb620e11cca76865fc2eb83cdb817fc5 +size 364655 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_6_en.png index 0041a870b93..63bd9c3f6d1 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a2bd77c08be705c66225f16c29fd6826decd92b41922d0f8ffc0d1a63d8c59d2 -size 356193 +oid sha256:a6cd94664f89bdad2a9761ea8b20acb83fed3ed7cb5be41af8fe4852643c0bfc +size 356175 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_8_en.png index 6e83d6491d8..d235670def7 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:34152c742abc486a48234e22101833bc163cf11979890d06aac41e68e3c404d0 -size 364846 +oid sha256:a00bd766a00b7e7c6615fc53bf469b3ca5056a6c514c096431ae38d99d604306 +size 364845 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_0_en.png index 77abe209821..422c324695f 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5c3d87862c6d7fc88d0f23cd54c5b9f77028992fdd7c282a195b964dd17e85ac -size 367855 +oid sha256:a1df8d22766981fc76d6d767dc3f40d98cadc7c51be20417b067b91af604232b +size 367884 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_10_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_10_en.png index 1d34761a9bd..448bcec6773 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f7d9b5fc0ff5d7828ad6f4dc0c9bd9520b4920553da97ed7b4e030ee844c0208 -size 353054 +oid sha256:fe0daf9161bc2da194992a2fc9505bf2009c5dbed78cdb35d2eb7ed1380a7df5 +size 353037 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_2_en.png index ceca184bd74..5560c219671 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8e82011a72f36889c53ab61ed2b9947c7fb5f868c9b7010904fc10f4ca587082 -size 356082 +oid sha256:af4a48ee80535f2c0a226c4ffa4344054fb63b7b0a88ea2c030a4eb8dd5e9644 +size 356078 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_4_en.png index a431a9429ae..e9d85990e8d 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:93c2df04d65f6078ce3f7067fbae8a7317ec42ed0fc013dee962a3953269f3b7 -size 362813 +oid sha256:66d4b6ea4a697735bbb0fac936b89cde99c5077973ba828894bf50b5dae233b3 +size 362848 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_6_en.png index ae8495618a9..aa322fc2e48 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:08057fdc1e1184720b438ddd2a8753f00791baf2456cc30b45de8e6bdeb57e8e -size 354404 +oid sha256:5fae87094ea13b14e2dc7c16ca96e4f8096ff39e962799468fb4ead4c666620f +size 354381 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_8_en.png index 1a8ea3e4e91..0cfd1dc9850 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:66aba7b4271a990fac85b0bbc7cbdc371fa1e38a4da5bb4e2f62a80b21cb3a8f -size 363012 +oid sha256:545c9e8933e420cc3f8fbba0fbd5162c946c65a20721faeb77c568596ca720cd +size 363045 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_3_en.png index b9723dd5c4f..67265ad1811 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:723238ad84c9925eb0edbbf225b50a03bf29e5671777d9f03eba6b910ee00fcf -size 488705 +oid sha256:923e46e5de89aba25a414e7ff434b82e0e59b3f38d32adcf72234901bfd49c59 +size 487648 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_3_en.png index 6ab414ab0b2..7a07b8c1bf8 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:80e198bd8cf523c785de4a66c4c22cecb22d1040377bdfd66eeed821a4281592 -size 481590 +oid sha256:85400acd84edfd9968e013f4cd1276b6f4834a81634b9bd2ba17f88b3c1191f8 +size 480656 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.blockedusers_BlockedUsersView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.blockedusers_BlockedUsersView_Day_4_en.png index 7cca911c455..074820e157d 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.blockedusers_BlockedUsersView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.blockedusers_BlockedUsersView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:26e14b77fa3c3aeaea58782309e6de8b9047e07945009322990ebf5f4c283309 -size 52770 +oid sha256:7caacb336793b8ae4b83e9a10a82325e25e8b3c036f881898ed28688250eb6e0 +size 55744 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.blockedusers_BlockedUsersView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.blockedusers_BlockedUsersView_Night_5_en.png index 616c06bf469..36d19c6b725 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.blockedusers_BlockedUsersView_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.blockedusers_BlockedUsersView_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0ce2519906f1aaf34700ca9ba4bdeaa72030f1f5aff3f7e24352a1435ac67d98 -size 53966 +oid sha256:7dfa2767c55a81d3f121b8fa55443b7b05f9369e91639b80e0be349863799ad7 +size 55438 diff --git a/tests/uitests/src/test/snapshots/images/features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Day_0_en.png index 0880cea78c1..625cb9e4292 100644 --- a/tests/uitests/src/test/snapshots/images/features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f648cf1318c4b2bb46a626d6d444ba602bcd40bf21ade7aa1f0cfbc71b6b3661 -size 16863 +oid sha256:cbf6d4801f297bed6a78b2a7ea6fe3fc842dbf25b523600f4995071a26707e07 +size 18513 diff --git a/tests/uitests/src/test/snapshots/images/features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Night_0_en.png index 5ae710e4dfa..27f8eb68a42 100644 --- a/tests/uitests/src/test/snapshots/images/features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:30b6854ad7a74c42953bdf1a00f2106504ceede002fd944b66c82a086e512cdf -size 15831 +oid sha256:b357a835c068cfacf4f304490eff060f5758750f407043f337fcff3e4c771398 +size 17471 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Day_0_en.png index 58546b8522b..ee5c582240f 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:30357bf18792db5ab20085010251170a724ffe8c00313facd04ee704e806af64 -size 10982 +oid sha256:75de341f705bb22955f9399180519b65ad59c2e1917317d56e50ea4db78f35bc +size 12102 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbar_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbar_Day_0_en.png index 83302d6ba49..38ad0e4b563 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbar_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbar_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:16dee1dfe746cd40dac9fe8f0af3ecb42cd14bab5cc490917b589beed065ed1b -size 14686 +oid sha256:326d59cf768b2dbd96eb7964d54c8eda509a5cd57c6e62087625e3314da0bb4d +size 13658 From 8f3d5480ee4221422ef5edc6b4f2095d2bc36c7c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 24 Jun 2026 09:03:05 +0200 Subject: [PATCH 25/52] Trigger CI From 82f86c22cc444cd74b19209fc055ff8ae213fb6c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 24 Jun 2026 09:11:55 +0200 Subject: [PATCH 26/52] Remove parameters with default value. --- .../event/TimelineItemAttachmentsListView.kt | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt index b51ba011345..4377ab3cb55 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt @@ -65,10 +65,7 @@ fun TimelineItemAttachmentsListView( content.attachments.forEachIndexed { index, attachment -> Column { if (index > 0) { - HorizontalDivider( - modifier = Modifier, - thickness = 1.dp, - ) + HorizontalDivider() } AttachmentListItem( attachment = attachment, @@ -80,10 +77,7 @@ fun TimelineItemAttachmentsListView( } if (content.showCaption) { - HorizontalDivider( - modifier = Modifier, - thickness = 1.dp, - ) + HorizontalDivider() val caption = if (LocalInspectionMode.current) { android.text.SpannedString(content.caption) } else { From c53f72f64fe34844525e70b17276239a2a2b0629 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 24 Jun 2026 09:18:31 +0200 Subject: [PATCH 27/52] More cleanup --- .../preview/AttachmentsPreviewView.kt | 5 ++-- .../event/TimelineItemAttachmentsListView.kt | 25 +++++++++++-------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt index 8de8a471668..5a39ddf1f24 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt @@ -9,7 +9,6 @@ package io.element.android.features.messages.impl.attachments.preview import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -42,7 +41,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview @@ -93,6 +91,7 @@ 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 @@ -330,7 +329,7 @@ private fun AttachmentPreviewContent( if (isScrolling) { isPillVisible = true } else { - delay(2000L) + delay(2000.milliseconds) isPillVisible = false } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt index 4377ab3cb55..fcda9c7ee75 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt @@ -7,6 +7,7 @@ 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 @@ -26,6 +27,7 @@ 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 @@ -45,6 +47,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.ui.strings.CommonStrings @@ -79,10 +82,10 @@ fun TimelineItemAttachmentsListView( if (content.showCaption) { HorizontalDivider() val caption = if (LocalInspectionMode.current) { - android.text.SpannedString(content.caption) + SpannedString(content.caption) } else { - (content.formattedCaption ?: android.text.SpannedString(content.caption)).let { - if (it is String) it else android.text.SpannedString(content.caption) + (content.formattedCaption ?: SpannedString(content.caption)).let { + if (it is String) it else SpannedString(content.caption) } } CompositionLocalProvider( @@ -155,7 +158,7 @@ private fun AttachmentListItem( modifier = Modifier .size(iconSize) .background( - color = androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.3f), + color = Color.Black.copy(alpha = 0.3f), shape = RoundedCornerShape(4.dp) ), contentAlignment = Alignment.Center, @@ -163,7 +166,7 @@ private fun AttachmentListItem( Icon( imageVector = CompoundIcons.VideoCallSolid(), contentDescription = stringResource(CommonStrings.common_video), - tint = androidx.compose.ui.graphics.Color.White, + tint = Color.White, modifier = Modifier.size(20.dp), ) } @@ -231,7 +234,7 @@ internal fun TimelineItemAttachmentsListViewPreview() = ElementPreview { AttachmentItem( filename = "document.pdf", mimeType = "application/pdf", - mediaSource = io.element.android.libraries.matrix.api.media.MediaSource(url = "", json = ""), + mediaSource = MediaSource(url = "", json = ""), thumbnailSource = null, fileSize = null, formattedFileSize = "2.5 MB", @@ -240,8 +243,8 @@ internal fun TimelineItemAttachmentsListViewPreview() = ElementPreview { AttachmentItem( filename = "photo.jpg", mimeType = "image/jpeg", - mediaSource = io.element.android.libraries.matrix.api.media.MediaSource(url = "", json = ""), - thumbnailSource = io.element.android.libraries.matrix.api.media.MediaSource(url = "thumb", json = ""), + mediaSource = MediaSource(url = "", json = ""), + thumbnailSource = MediaSource(url = "thumb", json = ""), fileSize = null, formattedFileSize = "1.2 MB", fileExtension = "JPG", @@ -249,7 +252,7 @@ internal fun TimelineItemAttachmentsListViewPreview() = ElementPreview { AttachmentItem( filename = "spreadsheet.xlsx", mimeType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - mediaSource = io.element.android.libraries.matrix.api.media.MediaSource(url = "", json = ""), + mediaSource = MediaSource(url = "", json = ""), thumbnailSource = null, fileSize = null, formattedFileSize = "450 KB", @@ -275,7 +278,7 @@ internal fun TimelineItemAttachmentsListViewWithCaptionPreview() = ElementPrevie AttachmentItem( filename = "report.pdf", mimeType = "application/pdf", - mediaSource = io.element.android.libraries.matrix.api.media.MediaSource(url = "", json = ""), + mediaSource = MediaSource(url = "", json = ""), thumbnailSource = null, fileSize = null, formattedFileSize = "3.2 MB", @@ -284,7 +287,7 @@ internal fun TimelineItemAttachmentsListViewWithCaptionPreview() = ElementPrevie AttachmentItem( filename = "notes.txt", mimeType = "text/plain", - mediaSource = io.element.android.libraries.matrix.api.media.MediaSource(url = "", json = ""), + mediaSource = MediaSource(url = "", json = ""), thumbnailSource = null, fileSize = null, formattedFileSize = "12 KB", From 07af009d8c071f0341686ff936acb2d2bdc447c4 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 24 Jun 2026 09:43:01 +0200 Subject: [PATCH 28/52] Restore sendAsFile behavior. --- .../MessageComposerPresenter.kt | 12 ++-- .../AttachmentsPreviewPresenterTest.kt | 61 ++++++++++++------- 2 files changed, 46 insertions(+), 27 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 932283efa68..e1e9703bbc7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -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 @@ -190,10 +189,10 @@ class MessageComposerPresenter( handlePickedMediaList(uris) } val filesPicker = mediaPickerProvider.registerFileMultiPicker(AnyMimeTypes) { uris -> - handlePickedMediaList(uris) + handlePickedMediaList(uris, sendAsFile = true) } val fileSinglePicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri, mimeType -> - handlePickedMedia(uri, mimeType) + handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream, sendAsFile = true) } val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri -> handlePickedMedia(uri, MimeTypes.Jpeg) @@ -278,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, @@ -645,10 +644,11 @@ class MessageComposerPresenter( private fun handlePickedMediaList( uris: List, + sendAsFile: Boolean = false, ) { if (uris.isEmpty()) return if (uris.size == 1) { - handlePickedMedia(uris.first()) + handlePickedMedia(uris.first(), sendAsFile = sendAsFile) return } val attachments = uris.map { uri -> @@ -658,7 +658,7 @@ class MessageComposerPresenter( name = null, formattedFileSize = null, ) - Attachment.Media(localMedia) + Attachment.Media(localMedia, sendAsFile = sendAsFile) }.toImmutableList() val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId navigator.navigateToPreviewAttachments(attachments, inReplyToEventId) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt index 21b27acf37d..dbfbdc53777 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt @@ -69,8 +69,8 @@ import io.element.android.tests.testutils.test import io.element.android.tests.testutils.testCoroutineDispatchers import io.mockk.every import io.mockk.mockk -import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope @@ -414,7 +414,6 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo))) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo)) - val failureState = awaitItem() assertThat(failureState.sendActionState).isEqualTo(SendActionState.Failure(failure, mediaUploadInfo)) sendFileResult.assertions().isCalledOnce() @@ -459,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 = { _, _, _, _, _ -> @@ -498,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 = { _, _, _, _, _ -> @@ -596,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( @@ -668,20 +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()), + 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, ) ), ) @@ -766,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() @@ -789,7 +795,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { ), ) val presenter = createAttachmentsPreviewPresenter( - localMedia = localMedia, + attachments = listOf(Attachment.Media(localMedia)), attachmentImageEditor = FakeAttachmentImageEditor( canEditResult = true, ) { @@ -816,7 +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()), + 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, @@ -905,10 +916,13 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { } private fun TestScope.createAttachmentsPreviewPresenter( - localMedia: LocalMedia = aLocalMedia( - uri = mockMediaUrl, + attachments: List = listOf( + aMediaAttachment( + aLocalMedia( + uri = mockMediaUrl, + ) + ), ), - attachments: ImmutableList = persistentListOf(aMediaAttachment(localMedia)), room: JoinedRoom = FakeJoinedRoom(), timelineMode: Timeline.Mode = Timeline.Mode.Live, permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(), @@ -931,16 +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")) + } }, ): AttachmentsPreviewPresenter { return AttachmentsPreviewPresenter( - attachments = attachments, + attachments = attachments.toImmutableList(), onDoneListener = onDoneListener, mediaSenderFactory = MediaSenderFactory { timelineMode -> DefaultMediaSender( From f524d2f2f812a019a03a4ae1862a7430368bc26a Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 24 Jun 2026 17:38:14 +0200 Subject: [PATCH 29/52] Improve Preview. --- ...eItemEventContentForGalleryViewProvider.kt | 137 ++++++++++ .../TimelineItemEventRowGalleryPreview.kt | 245 +----------------- .../TimelineItemGalleryContentProvider.kt | 113 ++++++++ .../event/TimelineItemGalleryView.kt | 242 +---------------- .../tests/konsist/KonsistPreviewTest.kt | 1 + 5 files changed, 262 insertions(+), 476 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventContentForGalleryViewProvider.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryContentProvider.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventContentForGalleryViewProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventContentForGalleryViewProvider.kt new file mode 100644 index 00000000000..793b9996f0c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventContentForGalleryViewProvider.kt @@ -0,0 +1,137 @@ +/* + * 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.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 { + override val values: Sequence + 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( + isVideo = true, + duration = Duration.parse("PT1M30S") + ), + aGalleryItem( + isVideo = true, + 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", + ), + ), + ), + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowGalleryPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowGalleryPreview.kt index 44695ed0ad5..22c3f354f25 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowGalleryPreview.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowGalleryPreview.kt @@ -9,257 +9,24 @@ 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.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 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() = ElementPreview { +internal fun TimelineItemEventRowWithGalleryPreview( + @PreviewParameter(TimelineItemEventContentForGalleryViewProvider::class) content: TimelineItemEventContent, +) = ElementPreview { Column { sequenceOf(false, true).forEach { isMine -> ATimelineItemEventRow( event = aTimelineItemEvent( isMine = isMine, - content = aTimelineItemGalleryContent( - caption = "My vacation photos", - items = listOf( - aGalleryItem(), - aGalleryItem(), - aGalleryItem(), - aGalleryItem(), - ), - ), - groupPosition = TimelineItemGroupPosition.Last, - ), - ) - } - } -} - -@PreviewsDayNight -@Composable -internal fun TimelineItemEventRowWithGalleryTwoItemsPreview() = ElementPreview { - Column { - sequenceOf(false, true).forEach { isMine -> - ATimelineItemEventRow( - event = aTimelineItemEvent( - isMine = isMine, - content = aTimelineItemGalleryContent( - items = listOf( - aGalleryItem(), - aGalleryItem(), - ), - ), - groupPosition = TimelineItemGroupPosition.Last, - ), - ) - } - } -} - -@PreviewsDayNight -@Composable -internal fun TimelineItemEventRowWithGalleryThreeItemsPreview() = ElementPreview { - Column { - sequenceOf(false, true).forEach { isMine -> - ATimelineItemEventRow( - event = aTimelineItemEvent( - isMine = isMine, - content = aTimelineItemGalleryContent( - caption = "Three photos", - items = listOf( - aGalleryItem(), - aGalleryItem(), - aGalleryItem(), - ), - ), - groupPosition = TimelineItemGroupPosition.Last, - ), - ) - } - } -} - -@PreviewsDayNight -@Composable -internal fun TimelineItemEventRowWithGalleryManyItemsPreview() = ElementPreview { - Column { - sequenceOf(false, true).forEach { isMine -> - ATimelineItemEventRow( - event = aTimelineItemEvent( - isMine = isMine, - content = aTimelineItemGalleryContent( - caption = "Many photos", - items = (1..8).map { aGalleryItem() }, - ), - groupPosition = TimelineItemGroupPosition.Last, - ), - ) - } - } -} - -@PreviewsDayNight -@Composable -internal fun TimelineItemEventRowWithGalleryVideoItemsPreview() = ElementPreview { - Column { - sequenceOf(false, true).forEach { isMine -> - ATimelineItemEventRow( - event = aTimelineItemEvent( - isMine = isMine, - content = aTimelineItemGalleryContent( - caption = "Videos", - items = listOf( - aGalleryItem( - isVideo = true, - duration = kotlin.time.Duration.parse("PT1M30S") - ), - aGalleryItem( - isVideo = true, - duration = kotlin.time.Duration.parse("PT45S") - ), - aGalleryItem(), - ), - ), - groupPosition = TimelineItemGroupPosition.Last, - ), - ) - } - } -} - -@PreviewsDayNight -@Composable -internal fun TimelineItemEventRowWithAttachmentsPreview() = ElementPreview { - Column { - sequenceOf(false, true).forEach { isMine -> - ATimelineItemEventRow( - event = aTimelineItemEvent( - isMine = isMine, - content = aTimelineItemAttachmentsContent( - caption = "Documents", - attachments = listOf( - anAttachmentItem( - filename = "document.pdf", - fileExtension = "pdf", - ), - anAttachmentItem( - filename = "presentation.pdf", - fileExtension = "pdf", - ), - anAttachmentItem( - filename = "spreadsheet.xlsx", - fileExtension = "xlsx", - ), - ), - ), - groupPosition = TimelineItemGroupPosition.Last, - ), - ) - } - } -} - -@PreviewsDayNight -@Composable -internal fun TimelineItemEventRowWithAttachmentsImagesPreview() = ElementPreview { - Column { - sequenceOf(false, true).forEach { isMine -> - ATimelineItemEventRow( - event = aTimelineItemEvent( - isMine = isMine, - content = 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, - ), - ), - ), - groupPosition = TimelineItemGroupPosition.Last, - ), - ) - } - } -} - -@PreviewsDayNight -@Composable -internal fun TimelineItemEventRowWithAttachmentsVideosPreview() = ElementPreview { - Column { - sequenceOf(false, true).forEach { isMine -> - ATimelineItemEventRow( - event = aTimelineItemEvent( - isMine = isMine, - content = 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", - ), - ), - ), - groupPosition = TimelineItemGroupPosition.Last, - ), - ) - } - } -} - -@PreviewsDayNight -@Composable -internal fun TimelineItemEventRowWithAttachmentsAudioPreview() = ElementPreview { - Column { - sequenceOf(false, true).forEach { isMine -> - ATimelineItemEventRow( - event = aTimelineItemEvent( - isMine = isMine, - content = 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", - ), - ), - ), + content = content, groupPosition = TimelineItemGroupPosition.Last, ), ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryContentProvider.kt new file mode 100644 index 00000000000..e5af9462b08 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryContentProvider.kt @@ -0,0 +1,113 @@ +/* + * 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 { + override val values: Sequence + get() = sequenceOf( + aTimelineItemGalleryContent( + caption = "My vacation photos", + items = listOf( + aGalleryItem(isVideo = false), + aGalleryItem(isVideo = true, duration = 65.seconds), + aGalleryItem(isVideo = false), + aGalleryItem(isVideo = false), + ), + ), + aTimelineItemGalleryContent( + items = listOf( + aGalleryItem(isVideo = false), + ), + ), + 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(isVideo = true, duration = 45.seconds), + aGalleryItem(isVideo = false), + aGalleryItem(isVideo = false), + ), + ), + 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(isVideo = false), + aGalleryItem(isVideo = false), + aGalleryItem(isVideo = true, duration = 120.seconds), + aGalleryItem(isVideo = false), + aGalleryItem(isVideo = false), + ), + ), + aTimelineItemGalleryContent( + caption = "Many photos", + items = (1..12).map { aGalleryItem(isVideo = it == 3) }, + ), + ) +} + +private fun aGalleryItem( + isVideo: Boolean = false, + 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, + isVideo = isVideo, + isAudio = false, + isFile = false, + duration = duration, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt index fd811f5ff1e..b2c1cd569e1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt @@ -36,6 +36,7 @@ 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 @@ -55,7 +56,6 @@ import io.element.android.wysiwyg.compose.EditorStyledText import io.element.android.wysiwyg.link.Link import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList -import kotlin.time.Duration.Companion.seconds private const val MAX_TILES = 5 private val GALLERY_WIDTH = 264.dp @@ -405,107 +405,11 @@ private fun RemainingCountOverlay(count: Int) { @PreviewsDayNight @Composable -internal fun TimelineItemGalleryViewPreview() = ElementPreview { +internal fun TimelineItemGalleryViewPreview( + @PreviewParameter(TimelineItemGalleryContentProvider::class) content: TimelineItemGalleryContent, +) = ElementPreview { TimelineItemGalleryView( - content = TimelineItemGalleryContent( - body = "Gallery", - caption = "My vacation photos", - formattedCaption = null, - isEdited = false, - items = listOf( - createSampleGalleryItem(isVideo = false), - createSampleGalleryItem(isVideo = true, duration = 65.seconds), - createSampleGalleryItem(isVideo = false), - createSampleGalleryItem(isVideo = false), - ), - ), - onContentClick = {}, - onLongClick = {}, - onLinkClick = {}, - onLinkLongClick = {}, - onContentLayoutChange = {}, - ) -} - -@PreviewsDayNight -@Composable -internal fun TimelineItemGalleryViewSingleItemPreview() = ElementPreview { - TimelineItemGalleryView( - content = TimelineItemGalleryContent( - body = "Gallery", - caption = null, - formattedCaption = null, - isEdited = false, - items = listOf( - createSampleGalleryItem(isVideo = false), - ), - ), - onContentClick = {}, - onLongClick = {}, - onLinkClick = {}, - onLinkLongClick = {}, - onContentLayoutChange = {}, - ) -} - -@PreviewsDayNight -@Composable -internal fun TimelineItemGalleryViewTwoLandscapePreview() = ElementPreview { - TimelineItemGalleryView( - content = TimelineItemGalleryContent( - body = "Gallery", - caption = null, - formattedCaption = null, - isEdited = false, - items = listOf( - createSampleGalleryItem(width = 1920, height = 1080), - createSampleGalleryItem(width = 1600, height = 900), - ), - ), - onContentClick = {}, - onLongClick = {}, - onLinkClick = {}, - onLinkLongClick = {}, - onContentLayoutChange = {}, - ) -} - -@PreviewsDayNight -@Composable -internal fun TimelineItemGalleryViewTwoPortraitPreview() = ElementPreview { - TimelineItemGalleryView( - content = TimelineItemGalleryContent( - body = "Gallery", - caption = null, - formattedCaption = null, - isEdited = false, - items = listOf( - createSampleGalleryItem(width = 1080, height = 1920), - createSampleGalleryItem(width = 900, height = 1600), - ), - ), - onContentClick = {}, - onLongClick = {}, - onLinkClick = {}, - onLinkLongClick = {}, - onContentLayoutChange = {}, - ) -} - -@PreviewsDayNight -@Composable -internal fun TimelineItemGalleryViewTwoMixedPreview() = ElementPreview { - TimelineItemGalleryView( - content = TimelineItemGalleryContent( - body = "Gallery", - caption = null, - formattedCaption = null, - isEdited = false, - items = listOf( - createSampleGalleryItem(width = 1920, height = 1080), - createSampleGalleryItem(width = 1080, height = 1920), - ), - ), + content = content, onContentClick = {}, onLongClick = {}, onLinkClick = {}, @@ -513,139 +417,3 @@ internal fun TimelineItemGalleryViewTwoMixedPreview() = ElementPreview { onContentLayoutChange = {}, ) } - -@PreviewsDayNight -@Composable -internal fun TimelineItemGalleryViewThreeItemsPreview() = ElementPreview { - TimelineItemGalleryView( - content = TimelineItemGalleryContent( - body = "Gallery", - caption = null, - formattedCaption = null, - isEdited = false, - items = listOf( - createSampleGalleryItem(isVideo = true, duration = 45.seconds), - createSampleGalleryItem(isVideo = false), - createSampleGalleryItem(isVideo = false), - ), - ), - onContentClick = {}, - onLongClick = {}, - onLinkClick = {}, - onLinkLongClick = {}, - onContentLayoutChange = {}, - ) -} - -@PreviewsDayNight -@Composable -internal fun TimelineItemGalleryViewThree2L1PPreview() = ElementPreview { - TimelineItemGalleryView( - content = TimelineItemGalleryContent( - body = "Gallery", - caption = "2 landscape + 1 portrait", - formattedCaption = null, - isEdited = false, - items = listOf( - createSampleGalleryItem(width = 1920, height = 1080), - createSampleGalleryItem(width = 1600, height = 900), - createSampleGalleryItem(width = 1080, height = 1920), - ), - ), - onContentClick = {}, - onLongClick = {}, - onLinkClick = {}, - onLinkLongClick = {}, - onContentLayoutChange = {}, - ) -} - -@PreviewsDayNight -@Composable -internal fun TimelineItemGalleryViewThree1L2PPreview() = ElementPreview { - TimelineItemGalleryView( - content = TimelineItemGalleryContent( - body = "Gallery", - caption = "1 landscape + 2 portrait", - formattedCaption = null, - isEdited = false, - items = listOf( - createSampleGalleryItem(width = 1920, height = 1080), - createSampleGalleryItem(width = 1080, height = 1920), - createSampleGalleryItem(width = 900, height = 1600), - ), - ), - onContentClick = {}, - onLongClick = {}, - onLinkClick = {}, - onLinkLongClick = {}, - onContentLayoutChange = {}, - ) -} - -@PreviewsDayNight -@Composable -internal fun TimelineItemGalleryViewFiveItemsPreview() = ElementPreview { - TimelineItemGalleryView( - content = TimelineItemGalleryContent( - body = "Gallery", - caption = null, - formattedCaption = null, - isEdited = false, - items = listOf( - createSampleGalleryItem(isVideo = false), - createSampleGalleryItem(isVideo = false), - createSampleGalleryItem(isVideo = true, duration = 120.seconds), - createSampleGalleryItem(isVideo = false), - createSampleGalleryItem(isVideo = false), - ), - ), - onContentClick = {}, - onLongClick = {}, - onLinkClick = {}, - onLinkLongClick = {}, - onContentLayoutChange = {}, - ) -} - -@PreviewsDayNight -@Composable -internal fun TimelineItemGalleryViewManyItemsPreview() = ElementPreview { - TimelineItemGalleryView( - content = TimelineItemGalleryContent( - body = "Gallery", - caption = "Many photos", - formattedCaption = null, - isEdited = false, - items = (1..12).map { createSampleGalleryItem(isVideo = it == 3) }, - ), - onContentClick = {}, - onLongClick = {}, - onLinkClick = {}, - onLinkLongClick = {}, - onContentLayoutChange = {}, - ) -} - -private fun createSampleGalleryItem( - isVideo: Boolean = false, - width: Int = 400, - height: Int = 300, - duration: kotlin.time.Duration = kotlin.time.Duration.ZERO, -): GalleryItem { - return GalleryItem( - filename = "photo.jpg", - mimeType = "image/jpeg", - mediaSource = io.element.android.libraries.matrix.api.media.MediaSource(url = "", json = ""), - thumbnailSource = null, - width = width, - height = height, - thumbnailWidth = width, - thumbnailHeight = height, - blurhash = null, - isVideo = isVideo, - isAudio = false, - isFile = false, - duration = duration, - ) -} diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt index 433237cd13a..3f1e1eeb099 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt @@ -149,6 +149,7 @@ class KonsistPreviewTest { "TimelineItemEventRowShieldPreview", "TimelineItemEventRowTimestampPreview", "TimelineItemEventRowUtdPreview", + "TimelineItemEventRowWithGalleryPreview", "TimelineItemEventRowWithManyReactionsPreview", "TimelineItemEventRowWithRRPreview", "TimelineItemEventRowWithReplyPreview", From 0f9a60165c5fb8f181b02504bfe34df84509da30 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 24 Jun 2026 17:42:12 +0200 Subject: [PATCH 30/52] Improve Preview. --- .../TimelineItemAttachmentsContentProvider.kt | 77 +++++++++++++++++++ .../event/TimelineItemAttachmentsListView.kt | 77 ++----------------- 2 files changed, 82 insertions(+), 72 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsContentProvider.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsContentProvider.kt new file mode 100644 index 00000000000..3e4236937fb --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsContentProvider.kt @@ -0,0 +1,77 @@ +/* + * 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.AttachmentItem +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.libraries.matrix.api.media.MediaSource + +class TimelineItemAttachmentsContentProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aTimelineItemAttachmentsContent( + body = "Files", + caption = null, + attachments = listOf( + AttachmentItem( + filename = "document.pdf", + mimeType = "application/pdf", + mediaSource = MediaSource(url = "", json = ""), + thumbnailSource = null, + fileSize = null, + formattedFileSize = "2.5 MB", + fileExtension = "PDF", + ), + AttachmentItem( + filename = "photo.jpg", + mimeType = "image/jpeg", + mediaSource = MediaSource(url = "", json = ""), + thumbnailSource = MediaSource(url = "thumb", json = ""), + fileSize = null, + formattedFileSize = "1.2 MB", + fileExtension = "JPG", + ), + AttachmentItem( + filename = "spreadsheet.xlsx", + mimeType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + mediaSource = MediaSource(url = "", json = ""), + thumbnailSource = null, + fileSize = null, + formattedFileSize = "450 KB", + fileExtension = "XLSX", + ), + ), + ), + aTimelineItemAttachmentsContent( + body = "Files", + caption = "Important documents", + attachments = listOf( + AttachmentItem( + filename = "report.pdf", + mimeType = "application/pdf", + mediaSource = MediaSource(url = "", json = ""), + thumbnailSource = null, + fileSize = null, + formattedFileSize = "3.2 MB", + fileExtension = "PDF", + ), + AttachmentItem( + filename = "notes.txt", + mimeType = "text/plain", + mediaSource = MediaSource(url = "", json = ""), + thumbnailSource = null, + fileSize = null, + formattedFileSize = "12 KB", + fileExtension = "TXT", + ), + ), + ), + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt index fcda9c7ee75..59f814e231e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt @@ -32,6 +32,7 @@ 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 @@ -47,7 +48,6 @@ 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.api.media.MediaSource import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.ui.strings.CommonStrings @@ -223,78 +223,11 @@ private fun AttachmentListItem( @PreviewsDayNight @Composable -internal fun TimelineItemAttachmentsListViewPreview() = ElementPreview { +internal fun TimelineItemAttachmentsListViewPreview( + @PreviewParameter(TimelineItemAttachmentsContentProvider::class) content: TimelineItemAttachmentsContent +) = ElementPreview { TimelineItemAttachmentsListView( - content = TimelineItemAttachmentsContent( - body = "Files", - caption = null, - formattedCaption = null, - isEdited = false, - attachments = listOf( - AttachmentItem( - filename = "document.pdf", - mimeType = "application/pdf", - mediaSource = MediaSource(url = "", json = ""), - thumbnailSource = null, - fileSize = null, - formattedFileSize = "2.5 MB", - fileExtension = "PDF", - ), - AttachmentItem( - filename = "photo.jpg", - mimeType = "image/jpeg", - mediaSource = MediaSource(url = "", json = ""), - thumbnailSource = MediaSource(url = "thumb", json = ""), - fileSize = null, - formattedFileSize = "1.2 MB", - fileExtension = "JPG", - ), - AttachmentItem( - filename = "spreadsheet.xlsx", - mimeType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - mediaSource = MediaSource(url = "", json = ""), - thumbnailSource = null, - fileSize = null, - formattedFileSize = "450 KB", - fileExtension = "XLSX", - ), - ), - ), - onContentClick = {}, - onContentLayoutChange = {}, - ) -} - -@PreviewsDayNight -@Composable -internal fun TimelineItemAttachmentsListViewWithCaptionPreview() = ElementPreview { - TimelineItemAttachmentsListView( - content = TimelineItemAttachmentsContent( - body = "Files", - caption = "Important documents", - formattedCaption = null, - isEdited = false, - attachments = listOf( - AttachmentItem( - filename = "report.pdf", - mimeType = "application/pdf", - mediaSource = MediaSource(url = "", json = ""), - thumbnailSource = null, - fileSize = null, - formattedFileSize = "3.2 MB", - fileExtension = "PDF", - ), - AttachmentItem( - filename = "notes.txt", - mimeType = "text/plain", - mediaSource = MediaSource(url = "", json = ""), - thumbnailSource = null, - fileSize = null, - formattedFileSize = "12 KB", - fileExtension = "TXT", - ), - ), - ), + content = content, onContentClick = {}, onContentLayoutChange = {}, ) From 357686f858e78a89c29d63e218bc9512dca3ad2e Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 24 Jun 2026 17:46:20 +0200 Subject: [PATCH 31/52] Improve Preview. --- .../TimelineItemAttachmentsContentProvider.kt | 21 ++++++------------- .../event/TimelineItemEventContentProvider.kt | 11 ++++++---- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsContentProvider.kt index 3e4236937fb..032229da828 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsContentProvider.kt @@ -8,9 +8,9 @@ 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.AttachmentItem 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 { @@ -20,29 +20,24 @@ class TimelineItemAttachmentsContentProvider : PreviewParameterProvider "image/jpeg" else -> "application/$fileExtension" }, - mediaSource = io.element.android.libraries.matrix.api.media.MediaSource(url = "", json = ""), - thumbnailSource = if (hasThumbnail) io.element.android.libraries.matrix.api.media.MediaSource(url = "", json = "") else null, + mediaSource = MediaSource(url = "", json = ""), + thumbnailSource = thumbnailSource ?: if (hasThumbnail) MediaSource(url = "", json = "") else null, fileSize = fileSize, formattedFileSize = formattedFileSize, fileExtension = fileExtension, From 6b4ec9c523b3c2e383602298134fefd5f492ca66 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 24 Jun 2026 18:35:04 +0200 Subject: [PATCH 32/52] Fix gallery sending cancel and retry issue --- .../preview/AttachmentsPreviewPresenter.kt | 14 +++++++------- .../attachments/preview/AttachmentsPreviewState.kt | 12 ++++++------ .../preview/AttachmentsPreviewStateProvider.kt | 4 ++-- .../attachments/AttachmentsPreviewPresenterTest.kt | 12 ++++++------ .../impl/attachments/SendActionStateTest.kt | 10 +++++----- 5 files changed, 26 insertions(+), 26 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt index 3eedf1d2eed..e1929e14267 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt @@ -288,9 +288,9 @@ class AttachmentsPreviewPresenter( ongoingSendAttachmentJob.value = null } - val mediaUploadInfo = sendActionState.value.mediaUploadInfo() - sendActionState.value = if (mediaUploadInfo != null) { - SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo)) + val mediaUploadInfoList = sendActionState.value.mediaUploadInfoList() + sendActionState.value = if (mediaUploadInfoList != null) { + SendActionState.Sending.ReadyToUpload(mediaUploadInfoList) } else { SendActionState.Idle } @@ -437,7 +437,7 @@ class AttachmentsPreviewPresenter( if (it is CancellationException) { throw it } else { - sendActionState.value = SendActionState.Failure(it, null) + sendActionState.value = SendActionState.Failure(it, emptyList()) return } } @@ -476,7 +476,7 @@ class AttachmentsPreviewPresenter( } private fun resetPreparedMedia(sendActionState: MutableState) { - sendActionState.value.mediaUploadInfo()?.let(::cleanUp) + sendActionState.value.mediaUploadInfoList()?.forEach(::cleanUp) mediaSender.cleanUp() sendActionState.value = SendActionState.Idle } @@ -488,7 +488,7 @@ class AttachmentsPreviewPresenter( inReplyToEventId: EventId?, ) = runCatchingExceptions { if (mediaUploadInfos.size == 1) { - sendActionState.value = SendActionState.Sending.Uploading(mediaUploadInfos.first()) + sendActionState.value = SendActionState.Sending.Uploading(mediaUploadInfos) mediaSender.sendPreProcessedMedia( mediaUploadInfo = mediaUploadInfos.first(), caption = caption, @@ -514,7 +514,7 @@ class AttachmentsPreviewPresenter( if (error is CancellationException) { throw error } else { - sendActionState.value = SendActionState.Failure(error, mediaUploadInfos.firstOrNull()) + sendActionState.value = SendActionState.Failure(error, mediaUploadInfos) } } ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt index faded71f44d..8d74844c806 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt @@ -41,16 +41,16 @@ sealed interface SendActionState { sealed interface Sending : SendActionState { data class Processing(val displayProgress: Boolean) : Sending data class ReadyToUpload(val mediaInfos: List) : Sending - data class Uploading(val mediaUploadInfo: MediaUploadInfo) : Sending + data class Uploading(val mediaInfos: List) : Sending } - data class Failure(val error: Throwable, val mediaUploadInfo: MediaUploadInfo?) : SendActionState + data class Failure(val error: Throwable, val mediaInfos: List) : SendActionState data object Done : SendActionState - fun mediaUploadInfo(): MediaUploadInfo? = when (this) { - is Sending.ReadyToUpload -> mediaInfos.firstOrNull() - is Sending.Uploading -> mediaUploadInfo - is Failure -> mediaUploadInfo + fun mediaUploadInfoList(): List? = when (this) { + is Sending.ReadyToUpload -> mediaInfos + is Sending.Uploading -> mediaInfos + is Failure -> mediaInfos else -> null } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt index 6c416a2c973..f786e9526bf 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt @@ -42,8 +42,8 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider Date: Wed, 24 Jun 2026 18:37:36 +0200 Subject: [PATCH 33/52] Ensure any previous job is cancelled. --- .../impl/attachments/preview/AttachmentsPreviewPresenter.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt index e1929e14267..28b6fdea603 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt @@ -149,6 +149,7 @@ class AttachmentsPreviewPresenter( 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 }, From 2d5529259159cc3a195306896807f570dce32f8d Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 24 Jun 2026 18:46:50 +0200 Subject: [PATCH 34/52] Fix issue in summary message --- .../utils/messagesummary/DefaultMessageSummaryFormatter.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt index 0e2e654c4bd..ed669c8a791 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt @@ -61,8 +61,8 @@ class DefaultMessageSummaryFormatter( 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 == 1) { + val extensions = content.attachments.take(3).joinToString() { it.fileExtension } + if (count <= 3) { extensions } else { "$extensions +${count - 3}" From a9a43ffd1002453438e6e995a7834adf2d5f8446 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 24 Jun 2026 18:48:32 +0200 Subject: [PATCH 35/52] Gallery feature is disabled by default. --- .../messages/impl/messagecomposer/MessageComposerPresenter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index e1e9703bbc7..858e872365e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -180,7 +180,7 @@ class MessageComposerPresenter( } val isSendGalleryMessagesEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.SendGalleryMessages) - .collectAsState(initial = true) + .collectAsState(initial = false) val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType -> handlePickedMedia(uri, mimeType) From 6f9d71df7986992300af5cee0d7d5454388efb9c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 24 Jun 2026 18:56:30 +0200 Subject: [PATCH 36/52] Kotlin convention --- .../libraries/matrix/api/timeline/item/event/MessageType.kt | 2 +- .../matrix/impl/timeline/item/event/EventMessageMapper.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt index eff49c88ef3..fdc538ec44a 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt @@ -114,7 +114,7 @@ sealed interface 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 Other(val itemType: String, val body: String) : GalleryItemType } data class OtherMessageType( diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt index 0cc213adfba..0811cd741d2 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt @@ -179,7 +179,7 @@ class EventMessageMapper { } is RustGalleryItemType.Other -> { GalleryItemType.Other( - itemtype = type.itemtype, + itemType = type.itemtype, body = type.body, ) } From 4220ac315ffdaf270b3b0a5d3e06d57d43cc37c6 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 24 Jun 2026 18:59:10 +0200 Subject: [PATCH 37/52] Remove useless parenthesis --- .../impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt index ed669c8a791..4ef86000437 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt @@ -61,7 +61,7 @@ class DefaultMessageSummaryFormatter( is TimelineItemGalleryContent -> context.getString(CommonStrings.common_gallery) is TimelineItemAttachmentsContent -> { val count = content.attachments.size - val extensions = content.attachments.take(3).joinToString() { it.fileExtension } + val extensions = content.attachments.take(3).joinToString { it.fileExtension } if (count <= 3) { extensions } else { From 8270513ef021287a50621336cc674fb68becb140 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Wed, 24 Jun 2026 18:45:05 +0000 Subject: [PATCH 38/52] Update screenshots --- ...components.event_TimelineItemAttachmentsListView_Day_1_en.png} | 0 ...mponents.event_TimelineItemAttachmentsListView_Night_1_en.png} | 0 ...imeline.components.event_TimelineItemGalleryView_Day_1_en.png} | 0 ...imeline.components.event_TimelineItemGalleryView_Day_2_en.png} | 0 ...imeline.components.event_TimelineItemGalleryView_Day_3_en.png} | 0 ...imeline.components.event_TimelineItemGalleryView_Day_4_en.png} | 0 ...imeline.components.event_TimelineItemGalleryView_Day_5_en.png} | 0 ...imeline.components.event_TimelineItemGalleryView_Day_6_en.png} | 0 ...imeline.components.event_TimelineItemGalleryView_Day_7_en.png} | 0 ...imeline.components.event_TimelineItemGalleryView_Day_8_en.png} | 0 ...imeline.components.event_TimelineItemGalleryView_Day_9_en.png} | 0 ...eline.components.event_TimelineItemGalleryView_Night_1_en.png} | 0 ...eline.components.event_TimelineItemGalleryView_Night_2_en.png} | 0 ...eline.components.event_TimelineItemGalleryView_Night_3_en.png} | 0 ...eline.components.event_TimelineItemGalleryView_Night_4_en.png} | 0 ...eline.components.event_TimelineItemGalleryView_Night_5_en.png} | 0 ...eline.components.event_TimelineItemGalleryView_Night_6_en.png} | 0 ...eline.components.event_TimelineItemGalleryView_Night_7_en.png} | 0 ...eline.components.event_TimelineItemGalleryView_Night_8_en.png} | 0 ...eline.components.event_TimelineItemGalleryView_Night_9_en.png} | 0 ...eline.components_TimelineItemEventRowWithGallery_Day_1_en.png} | 0 ...eline.components_TimelineItemEventRowWithGallery_Day_2_en.png} | 0 ...eline.components_TimelineItemEventRowWithGallery_Day_3_en.png} | 0 ...eline.components_TimelineItemEventRowWithGallery_Day_4_en.png} | 0 ...eline.components_TimelineItemEventRowWithGallery_Day_5_en.png} | 0 ...eline.components_TimelineItemEventRowWithGallery_Day_6_en.png} | 0 ...eline.components_TimelineItemEventRowWithGallery_Day_7_en.png} | 0 ...eline.components_TimelineItemEventRowWithGallery_Day_8_en.png} | 0 ...ine.components_TimelineItemEventRowWithGallery_Night_1_en.png} | 0 ...ine.components_TimelineItemEventRowWithGallery_Night_2_en.png} | 0 ...ine.components_TimelineItemEventRowWithGallery_Night_3_en.png} | 0 ...ine.components_TimelineItemEventRowWithGallery_Night_4_en.png} | 0 ...ine.components_TimelineItemEventRowWithGallery_Night_5_en.png} | 0 ...ine.components_TimelineItemEventRowWithGallery_Night_6_en.png} | 0 ...ine.components_TimelineItemEventRowWithGallery_Night_7_en.png} | 0 ...ine.components_TimelineItemEventRowWithGallery_Night_8_en.png} | 0 36 files changed, 0 insertions(+), 0 deletions(-) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components.event_TimelineItemAttachmentsListViewWithCaption_Day_0_en.png => features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Day_1_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components.event_TimelineItemAttachmentsListViewWithCaption_Night_0_en.png => features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Night_1_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components.event_TimelineItemGalleryViewSingleItem_Day_0_en.png => features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_1_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoLandscape_Day_0_en.png => features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_2_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoMixed_Day_0_en.png => features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_3_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoPortrait_Day_0_en.png => features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_4_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components.event_TimelineItemGalleryViewThreeItems_Day_0_en.png => features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_5_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree2L1P_Day_0_en.png => features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_6_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree1L2P_Day_0_en.png => features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_7_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components.event_TimelineItemGalleryViewFiveItems_Day_0_en.png => features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_8_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components.event_TimelineItemGalleryViewManyItems_Day_0_en.png => features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_9_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components.event_TimelineItemGalleryViewSingleItem_Night_0_en.png => features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_1_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoLandscape_Night_0_en.png => features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_2_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoMixed_Night_0_en.png => features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_3_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoPortrait_Night_0_en.png => features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_4_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components.event_TimelineItemGalleryViewThreeItems_Night_0_en.png => features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_5_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree2L1P_Night_0_en.png => features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_6_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree1L2P_Night_0_en.png => features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_7_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components.event_TimelineItemGalleryViewFiveItems_Night_0_en.png => features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_8_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components.event_TimelineItemGalleryViewManyItems_Night_0_en.png => features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_9_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryTwoItems_Day_0_en.png => features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_1_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryThreeItems_Day_0_en.png => features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_2_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryManyItems_Day_0_en.png => features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_3_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryVideoItems_Day_0_en.png => features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_4_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components_TimelineItemEventRowWithAttachments_Day_0_en.png => features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_5_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsImages_Day_0_en.png => features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_6_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsVideos_Day_0_en.png => features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_7_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsAudio_Day_0_en.png => features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_8_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryTwoItems_Night_0_en.png => features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_1_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryThreeItems_Night_0_en.png => features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_2_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryManyItems_Night_0_en.png => features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_3_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryVideoItems_Night_0_en.png => features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_4_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components_TimelineItemEventRowWithAttachments_Night_0_en.png => features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_5_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsImages_Night_0_en.png => features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_6_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsVideos_Night_0_en.png => features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_7_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsAudio_Night_0_en.png => features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_8_en.png} (100%) diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListViewWithCaption_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Day_1_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListViewWithCaption_Day_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Day_1_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListViewWithCaption_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Night_1_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListViewWithCaption_Night_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Night_1_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewSingleItem_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_1_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewSingleItem_Day_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_1_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoLandscape_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_2_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoLandscape_Day_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_2_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoMixed_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_3_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoMixed_Day_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_3_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoPortrait_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_4_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoPortrait_Day_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_4_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThreeItems_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_5_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThreeItems_Day_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_5_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree2L1P_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_6_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree2L1P_Day_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_6_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree1L2P_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_7_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree1L2P_Day_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_7_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewFiveItems_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_8_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewFiveItems_Day_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_8_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewManyItems_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_9_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewManyItems_Day_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_9_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewSingleItem_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_1_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewSingleItem_Night_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_1_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoLandscape_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_2_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoLandscape_Night_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_2_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoMixed_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_3_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoMixed_Night_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_3_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoPortrait_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_4_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoPortrait_Night_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_4_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThreeItems_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_5_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThreeItems_Night_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_5_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree2L1P_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_6_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree2L1P_Night_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_6_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree1L2P_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_7_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree1L2P_Night_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_7_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewFiveItems_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_8_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewFiveItems_Night_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_8_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewManyItems_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_9_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewManyItems_Night_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_9_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryTwoItems_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_1_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryTwoItems_Day_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_1_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryThreeItems_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_2_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryThreeItems_Day_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_2_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryManyItems_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_3_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryManyItems_Day_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_3_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryVideoItems_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_4_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryVideoItems_Day_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_4_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachments_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_5_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachments_Day_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_5_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsImages_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_6_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsImages_Day_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_6_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsVideos_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_7_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsVideos_Day_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_7_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsAudio_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_8_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsAudio_Day_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_8_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryTwoItems_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_1_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryTwoItems_Night_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_1_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryThreeItems_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_2_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryThreeItems_Night_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_2_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryManyItems_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_3_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryManyItems_Night_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_3_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryVideoItems_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_4_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryVideoItems_Night_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_4_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachments_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_5_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachments_Night_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_5_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsImages_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_6_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsImages_Night_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_6_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsVideos_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_7_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsVideos_Night_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_7_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsAudio_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_8_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsAudio_Night_0_en.png rename to tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_8_en.png From d8877c6581da9a38e83824944b924b972463c0e3 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 25 Jun 2026 09:48:05 +0200 Subject: [PATCH 39/52] Fix test --- .../impl/attachments/SendActionStateTest.kt | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/SendActionStateTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/SendActionStateTest.kt index d997df58ba3..b0bc755af89 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/SendActionStateTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/SendActionStateTest.kt @@ -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(mediaInfos = listOf(aMediaUploadInfo())) - assertThat(state.mediaUploadInfoList()).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(mediaInfos = listOf(aMediaUploadInfo())) - assertThat(state.mediaUploadInfoList()).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"), mediaInfos = listOf(aMediaUploadInfo())) - assertThat(state.mediaUploadInfoList()).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) } } From 8a971205d4a3130087e3c602cdf268f7af90752c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 25 Jun 2026 14:39:52 +0200 Subject: [PATCH 40/52] List -> ImmutableList --- .../factories/event/TimelineItemContentMessageFactory.kt | 2 +- .../timeline/model/event/TimelineItemEventContentProvider.kt | 3 ++- .../impl/timeline/model/event/TimelineItemGalleryContent.kt | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt index 6abd8fd1609..4fafcc18dca 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt @@ -353,7 +353,7 @@ class TimelineItemContentMessageFactory( caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() }, formattedCaption = formattedCaption, isEdited = content.isEdited, - items = galleryItems, + items = galleryItems.toImmutableList(), ) } else { val attachments = messageType.items.mapNotNull { item -> diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt index 3f3b15664da..a4810998d95 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt @@ -15,6 +15,7 @@ import androidx.core.text.buildSpannedString import androidx.core.text.inSpans import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent +import kotlinx.collections.immutable.toImmutableList import org.jsoup.nodes.Document class TimelineItemEventContentProvider : PreviewParameterProvider { @@ -127,7 +128,7 @@ fun aTimelineItemGalleryContent( caption = caption, formattedCaption = null, isEdited = false, - items = items, + items = items.toImmutableList(), ) fun aGalleryItem( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt index 812789cb2f4..84ca4114fe5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt @@ -9,6 +9,7 @@ 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( @@ -16,7 +17,7 @@ data class TimelineItemGalleryContent( val caption: String?, val formattedCaption: CharSequence?, override val isEdited: Boolean, - val items: List, + val items: ImmutableList, ) : TimelineItemEventContent, TimelineItemEventMutableContent { override val type: String = "TimelineItemGalleryContent" From 2994bba99995c2a0f2942eabc129232d909cfafa Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 25 Jun 2026 14:41:12 +0200 Subject: [PATCH 41/52] Remove useless code. --- .../impl/attachments/preview/AttachmentsPreviewPresenter.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt index 28b6fdea603..3ea9fc30e10 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt @@ -136,10 +136,6 @@ class AttachmentsPreviewPresenter( var displayFileTooLargeError by remember { mutableStateOf(false) } - LaunchedEffect(attachments) { - currentIndex = 0 - } - LaunchedEffect(mediaOptimizationSelectorState.displayMediaSelectorViews, mediaOptimizationSelectorState.selectedVideoPreset) { if (mediaOptimizationSelectorState.displayMediaSelectorViews == false && imageEditorState == null && From fd5d067f0d5f57d021877bc9c518fddd9d5f13d1 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 25 Jun 2026 14:48:31 +0200 Subject: [PATCH 42/52] Render formatted caption for attachment list. --- .../event/TimelineItemAttachmentsListView.kt | 28 +++++++++++++------ .../event/TimelineItemEventContentView.kt | 2 ++ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt index 59f814e231e..07ffca7b884 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt @@ -14,7 +14,9 @@ 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 @@ -49,12 +51,17 @@ 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, onContentClick: ((Int) -> Unit)?, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, ) { @@ -81,25 +88,26 @@ fun TimelineItemAttachmentsListView( if (content.showCaption) { HorizontalDivider() + Spacer(modifier = Modifier.height(8.dp)) val caption = if (LocalInspectionMode.current) { SpannedString(content.caption) } else { - (content.formattedCaption ?: SpannedString(content.caption)).let { - if (it is String) it else SpannedString(content.caption) - } + content.formattedCaption ?: SpannedString(content.caption) } CompositionLocalProvider( LocalContentColor provides ElementTheme.colors.textPrimary, LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular ) { - Text( + EditorStyledText( modifier = Modifier - .padding(top = 8.dp, start = 4.dp, end = 4.dp) + .padding(horizontal = 4.dp) .widthIn(min = 120.dp), - text = caption.toString(), - style = ElementTheme.typography.fontBodyLgRegular, - maxLines = 5, - overflow = TextOverflow.Ellipsis, + text = caption, + style = ElementRichTextEditorStyle.textStyle(), + onLinkClickedListener = onLinkClick, + onLinkLongClickedListener = onLinkLongClick, + releaseOnDetach = false, + onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange), ) } } @@ -229,6 +237,8 @@ internal fun TimelineItemAttachmentsListViewPreview( TimelineItemAttachmentsListView( content = content, onContentClick = {}, + onLinkClick = {}, + onLinkLongClick = {}, onContentLayoutChange = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt index 419ed56b037..12b4b1ae233 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt @@ -105,6 +105,8 @@ fun TimelineItemEventContentView( is TimelineItemAttachmentsContent -> TimelineItemAttachmentsListView( content = content, onContentClick = { index -> onGalleryItemClick?.invoke(index) }, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, onContentLayoutChange = {}, modifier = modifier, ) From d3fbfc3982cff988731b9e61a6c556dd6c83de15 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 25 Jun 2026 15:59:49 +0200 Subject: [PATCH 43/52] Replace set of Booleans by an enum --- .../messages/impl/MessagesFlowNode.kt | 16 ++++++-- ...eItemEventContentForGalleryViewProvider.kt | 5 ++- .../TimelineItemGalleryContentProvider.kt | 38 ++++++++++--------- .../event/TimelineItemGalleryView.kt | 2 +- .../TimelineItemContentMessageFactory.kt | 21 ++++------ .../event/TimelineItemEventContentProvider.kt | 23 +++++------ .../model/event/TimelineItemGalleryContent.kt | 13 +++++-- .../mediaviewer/api/GalleryItemData.kt | 13 +++++-- .../viewer/GalleryMediaGalleryDataSource.kt | 10 ++--- 9 files changed, 77 insertions(+), 64 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 5516cee22c0..6e4204afafc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -47,6 +47,7 @@ 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.TimelineItemAudioContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent @@ -735,12 +736,10 @@ class MessagesFlowNode( mimeType = galleryItem.mimeType, mediaSource = galleryItem.mediaSource, thumbnailSource = galleryItem.thumbnailSource, - isVideo = galleryItem.isVideo, - isAudio = galleryItem.isAudio, - isFile = galleryItem.isFile, + type = galleryItem.type.toMediaViewerType(), ) }.reversed() - val mode = if (event.content.items.any { it.isVideo || !it.isAudio && !it.isFile }) { + val mode = if (event.content.items.any { it.type.isMedia() }) { MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(timelineMode) } else { MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode) @@ -838,3 +837,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 + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventContentForGalleryViewProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventContentForGalleryViewProvider.kt index 793b9996f0c..63034d56e12 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventContentForGalleryViewProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventContentForGalleryViewProvider.kt @@ -8,6 +8,7 @@ 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 @@ -50,11 +51,11 @@ class TimelineItemEventContentForGalleryViewProvider : caption = "Videos", items = listOf( aGalleryItem( - isVideo = true, + type = GalleryItem.Type.Video, duration = Duration.parse("PT1M30S") ), aGalleryItem( - isVideo = true, + type = GalleryItem.Type.Video, duration = Duration.parse("PT45S") ), aGalleryItem(), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryContentProvider.kt index e5af9462b08..2a0c10f518f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryContentProvider.kt @@ -21,15 +21,15 @@ class TimelineItemGalleryContentProvider : PreviewParameterProvider { @@ -296,16 +294,14 @@ class TimelineItemContentMessageFactory( 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, - isVideo = true, - isAudio = false, - isFile = false, - duration = item.content.info?.duration ?: kotlin.time.Duration.ZERO, + duration = item.content.info?.duration ?: Duration.ZERO, ) } is GalleryItemType.Audio -> { @@ -313,15 +309,14 @@ class TimelineItemContentMessageFactory( 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, - isVideo = false, - isAudio = true, - isFile = false, + duration = item.content.info?.duration ?: Duration.ZERO, ) } is GalleryItemType.File -> { @@ -329,15 +324,13 @@ class TimelineItemContentMessageFactory( 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, - isVideo = false, - isAudio = false, - isFile = true, ) } is GalleryItemType.Other -> null @@ -345,7 +338,7 @@ class TimelineItemContentMessageFactory( } val hasPreviews = galleryItems.any { it.thumbnailSource != null } val isMediaGallery = galleryItems.all { item -> - item.isVideo || !item.isAudio && !item.isFile + item.type.isMedia() } if (isMediaGallery && hasPreviews) { TimelineItemGalleryContent( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt index a4810998d95..9945e872940 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt @@ -17,6 +17,7 @@ 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 { override val values = sequenceOf( @@ -135,28 +136,24 @@ fun aGalleryItem( filename: String = "photo.jpg", width: Int = 400, height: Int = 300, - isVideo: Boolean = false, - isAudio: Boolean = false, - isFile: Boolean = false, - duration: kotlin.time.Duration = kotlin.time.Duration.ZERO, + type: GalleryItem.Type = GalleryItem.Type.Image, + duration: Duration = Duration.ZERO, ) = GalleryItem( filename = filename, - mimeType = when { - isVideo -> "video/mp4" - isAudio -> "audio/mpeg" - isFile -> "application/pdf" - else -> "image/jpeg" + mimeType = when (type) { + GalleryItem.Type.Video -> "video/mp4" + GalleryItem.Type.Audio -> "audio/mpeg" + GalleryItem.Type.File -> "application/pdf" + GalleryItem.Type.Image -> "image/jpeg" }, - mediaSource = io.element.android.libraries.matrix.api.media.MediaSource(url = "", json = ""), + mediaSource = MediaSource(url = "", json = ""), + type = type, thumbnailSource = null, width = width, height = height, thumbnailWidth = width, thumbnailHeight = height, blurhash = null, - isVideo = isVideo, - isAudio = isAudio, - isFile = isFile, duration = duration, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt index 84ca4114fe5..be19228aed3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt @@ -28,17 +28,24 @@ 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 isVideo: Boolean, - val isAudio: Boolean, - val isFile: Boolean, 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, diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/GalleryItemData.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/GalleryItemData.kt index 70a14a663c1..3a2f2321db5 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/GalleryItemData.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/GalleryItemData.kt @@ -17,7 +17,12 @@ data class GalleryItemData( val mimeType: String, val mediaSource: MediaSource, val thumbnailSource: MediaSource?, - val isVideo: Boolean, - val isAudio: Boolean, - val isFile: Boolean, -) : Parcelable + val type: Type, +) : Parcelable { + enum class Type { + Image, + Video, + Audio, + File, + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSource.kt index f32af261e3d..8ce057bf8c2 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSource.kt @@ -56,27 +56,27 @@ class GalleryMediaGalleryDataSource( duration = null, ) val id = UniqueId("${eventId?.value ?: "gallery"}_$index") - val mediaItem: MediaItem.Event = when { - galleryItem.isVideo -> MediaItem.Video( + val mediaItem: MediaItem.Event = when (galleryItem.type) { + GalleryItemData.Type.Video -> MediaItem.Video( id = id, eventId = eventId, mediaInfo = itemMediaInfo, mediaSource = galleryItem.mediaSource, thumbnailSource = galleryItem.thumbnailSource, ) - galleryItem.isAudio -> MediaItem.Audio( + GalleryItemData.Type.Audio -> MediaItem.Audio( id = id, eventId = eventId, mediaInfo = itemMediaInfo, mediaSource = galleryItem.mediaSource, ) - galleryItem.isFile -> MediaItem.File( + GalleryItemData.Type.File -> MediaItem.File( id = id, eventId = eventId, mediaInfo = itemMediaInfo, mediaSource = galleryItem.mediaSource, ) - else -> MediaItem.Image( + GalleryItemData.Type.Image -> MediaItem.Image( id = id, eventId = eventId, mediaInfo = itemMediaInfo, From c88fa5f20df463d47e9f0a2eeec7bd044b141ca2 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 25 Jun 2026 16:12:18 +0200 Subject: [PATCH 44/52] Remove unused model for individual caption in a gallery Event. --- .../matrix/api/media/GalleryItemInfo.kt | 11 ----------- .../matrix/impl/media/GalleryItemInfoMapper.kt | 16 ++++++++-------- .../libraries/mediaupload/api/MediaUploadInfo.kt | 10 ---------- 3 files changed, 8 insertions(+), 29 deletions(-) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/GalleryItemInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/GalleryItemInfo.kt index 99e588ed56e..484e701dbcf 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/GalleryItemInfo.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/GalleryItemInfo.kt @@ -7,41 +7,30 @@ package io.element.android.libraries.matrix.api.media -import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody import java.io.File sealed interface GalleryItemInfo { val file: File - val caption: String? - val formattedCaption: FormattedBody? data class Image( override val file: File, val imageInfo: ImageInfo, val thumbnailFile: File?, - override val caption: String?, - override val formattedCaption: FormattedBody?, ) : GalleryItemInfo data class Video( override val file: File, val videoInfo: VideoInfo, val thumbnailFile: File?, - override val caption: String?, - override val formattedCaption: FormattedBody?, ) : GalleryItemInfo data class Audio( override val file: File, val audioInfo: AudioInfo, - override val caption: String?, - override val formattedCaption: FormattedBody?, ) : GalleryItemInfo data class MediaFile( override val file: File, val fileInfo: FileInfo, - override val caption: String?, - override val formattedCaption: FormattedBody?, ) : GalleryItemInfo } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryItemInfoMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryItemInfoMapper.kt index efd8447fa05..6e90ea3d621 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryItemInfoMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryItemInfoMapper.kt @@ -16,8 +16,8 @@ fun GalleryItemInfo.map(): RustGalleryItemInfo = when (this) { RustGalleryItemInfo.Image( imageInfo = imageInfo.map(), source = RustUploadSource.File(file.path), - caption = caption, - formattedCaption = formattedCaption?.map(), + caption = null, + formattedCaption = null, thumbnailSource = thumbnailFile?.path?.let(RustUploadSource::File), ) } @@ -25,8 +25,8 @@ fun GalleryItemInfo.map(): RustGalleryItemInfo = when (this) { RustGalleryItemInfo.Video( videoInfo = videoInfo.map(), source = RustUploadSource.File(file.path), - caption = caption, - formattedCaption = formattedCaption?.map(), + caption = null, + formattedCaption = null, thumbnailSource = thumbnailFile?.path?.let(RustUploadSource::File), ) } @@ -34,16 +34,16 @@ fun GalleryItemInfo.map(): RustGalleryItemInfo = when (this) { RustGalleryItemInfo.Audio( audioInfo = audioInfo.map(), source = RustUploadSource.File(file.path), - caption = caption, - formattedCaption = formattedCaption?.map(), + caption = null, + formattedCaption = null, ) } is GalleryItemInfo.MediaFile -> { RustGalleryItemInfo.File( fileInfo = fileInfo.map(), source = RustUploadSource.File(file.path), - caption = caption, - formattedCaption = formattedCaption?.map(), + caption = null, + formattedCaption = null, ) } } diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt index 3f3bf280a4d..29adab2baf3 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt @@ -39,33 +39,23 @@ fun MediaUploadInfo.toGalleryItemInfo(): GalleryItemInfo { file = file, imageInfo = imageInfo, thumbnailFile = thumbnailFile, - caption = null, - formattedCaption = null, ) is MediaUploadInfo.Video -> GalleryItemInfo.Video( file = file, videoInfo = videoInfo, thumbnailFile = thumbnailFile, - caption = null, - formattedCaption = null, ) is MediaUploadInfo.Audio -> GalleryItemInfo.Audio( file = file, audioInfo = audioInfo, - caption = null, - formattedCaption = null, ) is MediaUploadInfo.VoiceMessage -> GalleryItemInfo.Audio( file = file, audioInfo = audioInfo, - caption = null, - formattedCaption = null, ) is MediaUploadInfo.AnyFile -> GalleryItemInfo.MediaFile( file = file, fileInfo = fileInfo, - caption = null, - formattedCaption = null, ) } } From cd9a53205273a8a2b0aa15f90eb96ed2e9814bc4 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 25 Jun 2026 17:02:38 +0200 Subject: [PATCH 45/52] Fix tests --- .../impl/DefaultPinnedMessagesBannerFormatterTest.kt | 4 ++++ .../impl/DefaultRoomLatestEventFormatterTest.kt | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt index d36f262216d..198da2bf7df 100644 --- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt +++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt @@ -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" diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt index 44e407dcdc8..791a363f77d 100644 --- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt +++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt @@ -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") From 861e59d18b4783bcbdcf1a019aeb44494092946b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 25 Jun 2026 17:21:00 +0200 Subject: [PATCH 46/52] Fix tests --- .../mediaviewer/impl/viewer/MediaViewerPresenterTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt index 459d30b8d5f..a3004e1f905 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt @@ -639,7 +639,7 @@ class MediaViewerPresenterTest { } ) ) - skipItems(2) + skipItems(1) val stateWithSnackbar = awaitItem() assertThat(stateWithSnackbar.snackbarMessage!!.messageResId).isEqualTo(expectedSnackbarResId) } From c110ab8a7233d476b4a489b25ea6a3a524965d47 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 25 Jun 2026 23:20:25 +0200 Subject: [PATCH 47/52] Rework MediaViewer entry point. And ensure that the clicked image from the gallery is displayed first. --- .../messages/impl/MessagesFlowNode.kt | 161 ++++++++++++------ .../features/messages/impl/MessagesNode.kt | 16 +- .../features/messages/impl/MessagesView.kt | 55 +++--- .../MessagesViewWithIdentityChangePreview.kt | 1 + .../impl/threads/ThreadedMessagesNode.kt | 18 +- .../messages/impl/timeline/TimelineView.kt | 3 +- .../TimelineViewMessageShieldPreview.kt | 1 + .../messages/impl/MessagesViewTest.kt | 2 + .../impl/timeline/TimelineViewTest.kt | 2 + .../libraries/mediaviewer/api/MediaInfo.kt | 17 ++ .../mediaviewer/api/MediaViewerEntryPoint.kt | 33 ++-- .../impl/DefaultMediaViewerEntryPoint.kt | 26 +-- .../impl/gallery/root/MediaGalleryFlowNode.kt | 3 +- .../viewer/GalleryMediaGalleryDataSource.kt | 15 +- .../impl/viewer/MediaViewerDataSource.kt | 1 - .../impl/viewer/MediaViewerNode.kt | 85 ++++----- .../impl/viewer/MediaViewerPresenter.kt | 57 +++++-- .../viewer/SingleMediaGalleryDataSource.kt | 31 +++- .../impl/DefaultMediaViewerEntryPointTest.kt | 27 +-- .../impl/viewer/MediaViewerPresenterTest.kt | 31 ++-- .../SingleMediaGalleryDataSourceTest.kt | 13 +- 21 files changed, 343 insertions(+), 255 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 6e4204afafc..1ba73a48e49 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -88,6 +88,7 @@ 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 @@ -153,6 +154,13 @@ class MessagesFlowNode( val mediaSource: MediaSource, val thumbnailSource: MediaSource?, val canUseOverlay: Boolean, + ) : NavTarget + + @Parcelize + data class GalleryViewer( + val eventId: EventId?, + val galleryInfo: GalleryInfo, + val canUseOverlay: Boolean, val galleryItems: List = emptyList(), ) : NavTarget @@ -247,12 +255,22 @@ class MessagesFlowNode( timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean, - galleryItemIndex: Int?, ): Boolean { return processEventClick( timelineMode = timelineMode, event = event, canUseOverlay = canUseOverlay, + ) + } + + override fun handleGalleryItemClick( + event: TimelineItem.Event, + canUseOverlay: Boolean, + galleryItemIndex: Int, + ): Boolean { + return processGalleryEventClick( + event = event, + canUseOverlay = canUseOverlay, galleryItemIndex = galleryItemIndex, ) } @@ -349,13 +367,42 @@ class MessagesFlowNode( createNode(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 { @@ -505,12 +552,22 @@ class MessagesFlowNode( timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean, - galleryItemIndex: Int?, ): Boolean { return processEventClick( timelineMode = timelineMode, event = event, canUseOverlay = canUseOverlay, + ) + } + + override fun handleGalleryItemClick( + event: TimelineItem.Event, + canUseOverlay: Boolean, + galleryItemIndex: Int, + ): Boolean { + return processGalleryEventClick( + event = event, + canUseOverlay = canUseOverlay, galleryItemIndex = galleryItemIndex, ) } @@ -641,7 +698,6 @@ class MessagesFlowNode( timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean, - galleryItemIndex: Int? = null, ): Boolean { val navTarget = when (event.content) { is TimelineItemImageContent -> { @@ -703,57 +759,6 @@ class MessagesFlowNode( null } } - is TimelineItemGalleryContent -> { - val item = if (galleryItemIndex != null) { - event.content.items.getOrNull(galleryItemIndex) - } else { - event.content.items.firstOrNull() - } ?: return false - val mediaInfo = MediaInfo( - filename = item.filename, - fileSize = null, - caption = event.content.caption, - mimeType = item.mimeType, - formattedFileSize = "", - fileExtension = item.filename.substringAfterLast('.', ""), - 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, - ), - waveform = null, - duration = null, - ) - 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() - val mode = if (event.content.items.any { it.type.isMedia() }) { - MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(timelineMode) - } else { - MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode) - } - NavTarget.MediaViewer( - mode = mode, - eventId = event.eventId, - mediaInfo = mediaInfo, - mediaSource = item.mediaSource, - thumbnailSource = item.thumbnailSource, - canUseOverlay = canUseOverlay, - galleryItems = galleryItems, - ) - } else -> null } return when (navTarget) { @@ -773,6 +778,50 @@ class MessagesFlowNode( } } + private fun processGalleryEventClick( + event: TimelineItem.Event, + canUseOverlay: Boolean, + galleryItemIndex: Int, + ): Boolean { + if (event.content !is TimelineItemGalleryContent) return false + val galleryInfo = GalleryInfo( + caption = event.content.caption, + senderId = event.senderId, + senderName = event.safeSenderName, + senderAvatar = event.senderAvatar.url, + dateSent = dateFormatter.format( + event.sentTimeMillis, + mode = DateFormatterMode.Day, + ), + dateSentFull = dateFormatter.format( + timestamp = event.sentTimeMillis, + mode = DateFormatterMode.Full, + ), + initialIndex = galleryItemIndex, + ) + val galleryItems = event.content.items.map { galleryItem -> + GalleryItemData( + filename = galleryItem.filename, + mimeType = galleryItem.mimeType, + mediaSource = galleryItem.mediaSource, + thumbnailSource = galleryItem.thumbnailSource, + type = galleryItem.type.toMediaViewerType(), + ) + }.reversed() + val navTarget = NavTarget.GalleryViewer( + eventId = event.eventId, + galleryInfo = galleryInfo, + canUseOverlay = canUseOverlay, + galleryItems = galleryItems, + ) + if (canUseOverlay) { + overlay.show(navTarget) + } else { + backstack.push(navTarget) + } + return true + } + private fun buildMediaViewerNavTarget( mode: MediaViewerEntryPoint.MediaViewerMode, event: TimelineItem.Event, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index 06d239c4cb2..cd68d9d27c7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -118,7 +118,8 @@ class MessagesNode( ) interface Callback : Plugin { - fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean, galleryItemIndex: Int? = null): Boolean + fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean + fun handleGalleryItemClick(event: TimelineItem.Event, canUseOverlay: Boolean, galleryItemIndex: Int): Boolean fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) fun navigateToRoomMemberDetails(userId: UserId) fun handlePermalinkClick(data: PermalinkData) @@ -289,17 +290,8 @@ class MessagesNode( } } }, - onGalleryItemClick = { isLive, event, index -> - if (isLive) { - callback.handleEventClick(timelineController.mainTimelineMode(), event, canUseOverlay, index) - } else { - val detachedTimelineMode = timelineController.detachedTimelineMode() - if (detachedTimelineMode != null) { - callback.handleEventClick(detachedTimelineMode, event, canUseOverlay, index) - } else { - false - } - } + onGalleryEventItemClick = { event, index -> + callback.handleGalleryItemClick(event, canUseOverlay, index) }, onUserDataClick = callback::navigateToRoomMemberDetails, onLinkClick = { url, customTab -> diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 3f72f9ee20c..1e9471fa4b8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -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, @@ -144,7 +145,6 @@ fun MessagesView( onViewAllPinnedMessagesClick: () -> Unit, onThreadsListClick: () -> Unit, modifier: Modifier = Modifier, - onGalleryItemClick: ((isLive: Boolean, event: TimelineItem.Event, index: Int) -> Boolean)? = null, forceJumpToBottomVisibility: Boolean = false, knockRequestsBannerView: @Composable () -> Unit, ) { @@ -208,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, @@ -253,17 +253,14 @@ fun MessagesView( content = { padding -> Box( modifier = Modifier - .padding(padding) - .consumeWindowInsets(padding) + .padding(padding) + .consumeWindowInsets(padding) ) { MessagesViewContent( state = state, onContentClick = ::onContentClick, - onGalleryItemClick = { evt, idx -> - Timber.v("onGalleryItemClick= ${evt.id} index=$idx") - val isLive = state.timelineState.isLive - val handledByGallery = onGalleryItemClick?.invoke(isLive, evt, idx) - val hideKeyboard = handledByGallery ?: onEventContentClick(isLive, evt) + onGalleryItemClick = { event, index -> + val hideKeyboard = onGalleryEventItemClick(event, index) if (hideKeyboard) { localView.hideKeyboard() } @@ -300,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, @@ -469,20 +466,20 @@ 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, forceJumpToBottomVisibility: Boolean, onSwipeToReply: (TimelineItem.Event) -> Unit, modifier: Modifier = Modifier, - onGalleryItemClick: ((TimelineItem.Event, Int) -> Unit)? = null, knockRequestsBannerView: @Composable () -> Unit, ) { Box( modifier = modifier - .fillMaxSize() - .navigationBarsPadding() - .imePadding(), + .fillMaxSize() + .navigationBarsPadding() + .imePadding(), ) { AttachmentsBottomSheet( state = state.composerState, @@ -610,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 ) { @@ -658,6 +655,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class) forceJumpToBottomVisibility = true, knockRequestsBannerView = {}, onThreadsListClick = {}, + onGalleryEventItemClick = { _, _ -> false }, ) } @@ -711,6 +709,7 @@ internal fun MessagesViewA11yPreview() = ElementPreview { onJoinCallClick = {}, onViewAllPinnedMessagesClick = {}, onThreadsListClick = {}, + onGalleryEventItemClick = { _, _ -> false }, forceJumpToBottomVisibility = true, knockRequestsBannerView = {}, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt index 2c1a1bbe23b..2bad9a64d9d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt @@ -43,5 +43,6 @@ internal fun MessagesViewWithIdentityChangePreview( onViewAllPinnedMessagesClick = {}, knockRequestsBannerView = {}, onThreadsListClick = {}, + onGalleryEventItemClick = { _, _ -> false }, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt index 374bb6cc625..d9b8150fd23 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt @@ -125,7 +125,8 @@ class ThreadedMessagesNode( } interface Callback : Plugin { - fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean, galleryItemIndex: Int? = null): Boolean + fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean + fun handleGalleryItemClick(event: TimelineItem.Event, canUseOverlay: Boolean, galleryItemIndex: Int): Boolean fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) fun navigateToRoomMemberDetails(userId: UserId) fun handlePermalinkClick(data: PermalinkData) @@ -289,19 +290,8 @@ class ThreadedMessagesNode( } } == true }, - onGalleryItemClick = { isLive, event, index -> - timelineController?.let { controller -> - if (isLive) { - callback.handleEventClick(controller.mainTimelineMode(), event, canUseOverlay, index) - } else { - val detachedTimelineMode = controller.detachedTimelineMode() - if (detachedTimelineMode != null) { - callback.handleEventClick(detachedTimelineMode, event, canUseOverlay, index) - } else { - false - } - } - } == true + onGalleryEventItemClick = { event, index -> + callback.handleGalleryItemClick(event, canUseOverlay, index) }, onUserDataClick = callback::navigateToRoomMemberDetails, onLinkClick = { url, customTab -> diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index 9850b8ca76c..56deb75a657 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -103,8 +103,8 @@ fun TimelineView( onReactionLongClick: (emoji: String, TimelineItem.Event) -> Unit, onMoreReactionsClick: (TimelineItem.Event) -> Unit, onReadReceiptClick: (TimelineItem.Event) -> Unit, + onGalleryItemClick: ((TimelineItem.Event, Int) -> Unit), modifier: Modifier = Modifier, - onGalleryItemClick: ((TimelineItem.Event, Int) -> Unit)? = null, lazyListState: LazyListState = rememberLazyListState(), forceJumpToBottomVisibility: Boolean = false, nestedScrollConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(), @@ -430,6 +430,7 @@ internal fun TimelineViewPreview( onReactionLongClick = { _, _ -> }, onMoreReactionsClick = {}, onReadReceiptClick = {}, + onGalleryItemClick = { _, _ -> }, forceJumpToBottomVisibility = true, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewMessageShieldPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewMessageShieldPreview.kt index f0a11081e13..32d4aded2b5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewMessageShieldPreview.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewMessageShieldPreview.kt @@ -49,6 +49,7 @@ internal fun TimelineViewMessageShieldPreview() = ElementPreview { onReactionLongClick = { _, _ -> }, onMoreReactionsClick = {}, onReadReceiptClick = {}, + onGalleryItemClick = { _, _ -> }, forceJumpToBottomVisibility = true, ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt index 30106cbaf3d..4e0556df1f6 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt @@ -685,6 +685,7 @@ private fun AndroidComposeUiTest.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.setMessagesView( onBackClick = onBackClick, onRoomDetailsClick = onRoomDetailsClick, onEventContentClick = onEventClick, + onGalleryEventItemClick = onGalleryEventItemClick, onUserDataClick = onUserDataClick, onLinkClick = onLinkClick, onSendLocationClick = onSendLocationClick, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt index 1637dd7a853..dc63817e99e 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt @@ -216,6 +216,7 @@ private fun AndroidComposeUiTest.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.setTimelineView( onReactionLongClick = onReactionLongClick, onMoreReactionsClick = onMoreReactionsClick, onReadReceiptClick = onReadReceiptClick, + onGalleryItemClick = onGalleryItemClick, forceJumpToBottomVisibility = forceJumpToBottomVisibility, ) } diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt index adfffd8af49..6d70747a072 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt @@ -13,6 +13,23 @@ import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.core.UserId import kotlinx.parcelize.Parcelize +@Parcelize +data class GalleryInfo( + val caption: String?, + val formattedCaption: CharSequence? = null, + val senderId: UserId?, + val senderName: String?, + val senderAvatar: String?, + val dateSent: String?, + val dateSentFull: String?, + val initialIndex: Int, +) : Parcelable + +@Parcelize +data class AvatarInfo( + val filename: String, +) : Parcelable + @Parcelize data class MediaInfo( val filename: String, diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt index 05e6d1bdb50..ba9f371df18 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt @@ -35,20 +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, - val galleryItems: List = emptyList(), - ) : NodeInputs + sealed interface Params : NodeInputs { + data class RoomMedia( + val mode: MediaViewerMode, + val eventId: EventId?, + val mediaInfo: MediaInfo, + val mediaSource: MediaSource, + val thumbnailSource: MediaSource?, + ) : Params - sealed interface MediaViewerMode : Parcelable { - @Parcelize - data object SingleMedia : MediaViewerMode + data class EventGallery( + val eventId: EventId?, + val galleryInfo: GalleryInfo, + val galleryItems: List, + ) : Params + data class Avatar( + val avatarInfo: AvatarInfo, + val mediaSource: MediaSource, + val thumbnailSource: MediaSource?, + ) : Params + } + + sealed interface MediaViewerMode : Parcelable { @Parcelize data class TimelineImagesAndVideos(val timelineMode: Timeline.Mode) : MediaViewerMode diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt index 138f6382d20..10858c76c86 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt @@ -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, ) } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryFlowNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryFlowNode.kt index 5d6a48c25b0..611d619d520 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryFlowNode.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryFlowNode.kt @@ -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, ) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSource.kt index 8ce057bf8c2..d7ce4862633 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSource.kt @@ -11,6 +11,7 @@ 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 @@ -34,7 +35,7 @@ class GalleryMediaGalleryDataSource( fun createFrom( eventId: EventId?, galleryItems: List, - mediaInfo: MediaInfo, + galleryInfo: GalleryInfo, ): GalleryMediaGalleryDataSource { val imageAndVideoItems = mutableListOf() val fileItems = mutableListOf() @@ -43,15 +44,15 @@ class GalleryMediaGalleryDataSource( val itemMediaInfo = MediaInfo( filename = galleryItem.filename, fileSize = null, - caption = mediaInfo.caption, + caption = galleryInfo.caption, mimeType = galleryItem.mimeType, formattedFileSize = "", fileExtension = galleryItem.filename.substringAfterLast('.', ""), - senderId = mediaInfo.senderId, - senderName = mediaInfo.senderName, - senderAvatar = mediaInfo.senderAvatar, - dateSent = mediaInfo.dateSent, - dateSentFull = mediaInfo.dateSentFull, + senderId = galleryInfo.senderId, + senderName = galleryInfo.senderName, + senderAvatar = galleryInfo.senderAvatar, + dateSent = galleryInfo.dateSent, + dateSentFull = galleryInfo.dateSentFull, waveform = null, duration = null, ) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt index d7685796daa..8e9d5a9fdfc 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt @@ -62,7 +62,6 @@ class MediaViewerDataSource( private val mediaFiles: ConcurrentHashMap = ConcurrentHashMap() private val galleryMode = when (mode) { - MediaViewerMode.SingleMedia, is MediaViewerMode.TimelineImagesAndVideos -> MediaGalleryMode.Images is MediaViewerMode.TimelineFilesAndAudios -> MediaGalleryMode.Files } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt index c571497456f..23f0961af04 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt @@ -74,48 +74,49 @@ class MediaViewerNode( callback.onDone() } - private val mediaGallerySource = if (inputs.mode == MediaViewerEntryPoint.MediaViewerMode.SingleMedia) { - SingleMediaGalleryDataSource.createFrom(inputs) - } else if (inputs.galleryItems.isNotEmpty()) { - GalleryMediaGalleryDataSource.createFrom( - eventId = inputs.eventId, - galleryItems = inputs.galleryItems, - mediaInfo = inputs.mediaInfo, - ) - } 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 } } } @@ -124,7 +125,14 @@ class MediaViewerNode( inputs = inputs, navigator = this, dataSource = MediaViewerDataSource( - mode = inputs.mode, + mode = when (inputs) { + is MediaViewerEntryPoint.Params.Avatar -> + MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media) + is MediaViewerEntryPoint.Params.EventGallery -> + MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media) + is MediaViewerEntryPoint.Params.RoomMedia -> + inputs.mode + }, coroutineScope = lifecycleScope, dispatcher = coroutineDispatchers.computation, galleryDataSource = mediaGallerySource, @@ -155,10 +163,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 } } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt index 2c17b88f54d..b379f91b316 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt @@ -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, inputs.mediaSource) ?: 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, inputs.mediaSource) ?: 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, inputs.mediaSource) ?: 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 +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSource.kt index 146ace86207..6809f6b6856 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSource.kt @@ -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"), diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPointTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPointTest.kt index e7d45f433a4..9cd134b843a 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPointTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPointTest.kt @@ -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) diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt index a3004e1f905..f917e8e0d1c 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt @@ -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( @@ -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, ) diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSourceTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSourceTest.kt index 8c0a7c05d0e..a8696909079 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSourceTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSourceTest.kt @@ -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, ) } From 0fcf5236a5afddfef6dae007e19fc0d982d6af9c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 26 Jun 2026 10:07:17 +0200 Subject: [PATCH 48/52] Ensure gallery item can be click in the pinned message list Improve the gallery item click handling code. --- .../features/messages/impl/MessagesFlowNode.kt | 18 +++++++++++++++--- .../features/messages/impl/MessagesNode.kt | 4 ++-- .../impl/pinned/list/PinnedMessagesListNode.kt | 4 ++++ .../impl/pinned/list/PinnedMessagesListView.kt | 10 ++++++++++ .../impl/threads/ThreadedMessagesNode.kt | 4 ++-- .../messages/impl/timeline/TimelineView.kt | 2 +- .../components/ATimelineItemEventRow.kt | 1 + .../components/TimelineItemEventRow.kt | 2 +- .../components/TimelineItemGroupedEventsRow.kt | 3 +++ .../timeline/components/TimelineItemRow.kt | 6 +++--- .../components/TimelineItemStateEventRow.kt | 1 + .../event/TimelineItemEventContentView.kt | 6 +++--- .../pinned/list/PinnedMessagesListViewTest.kt | 3 +++ 13 files changed, 49 insertions(+), 15 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 1ba73a48e49..651523af7b7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -265,8 +265,8 @@ class MessagesFlowNode( override fun handleGalleryItemClick( event: TimelineItem.Event, - canUseOverlay: Boolean, galleryItemIndex: Int, + canUseOverlay: Boolean, ): Boolean { return processGalleryEventClick( event = event, @@ -513,6 +513,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) } @@ -562,8 +574,8 @@ class MessagesFlowNode( override fun handleGalleryItemClick( event: TimelineItem.Event, - canUseOverlay: Boolean, galleryItemIndex: Int, + canUseOverlay: Boolean, ): Boolean { return processGalleryEventClick( event = event, @@ -780,8 +792,8 @@ class MessagesFlowNode( private fun processGalleryEventClick( event: TimelineItem.Event, - canUseOverlay: Boolean, galleryItemIndex: Int, + canUseOverlay: Boolean, ): Boolean { if (event.content !is TimelineItemGalleryContent) return false val galleryInfo = GalleryInfo( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index cd68d9d27c7..1eb3a82e1ab 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -119,7 +119,7 @@ class MessagesNode( interface Callback : Plugin { fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean - fun handleGalleryItemClick(event: TimelineItem.Event, canUseOverlay: Boolean, galleryItemIndex: Int): Boolean + fun handleGalleryItemClick(event: TimelineItem.Event, galleryItemIndex: Int, canUseOverlay: Boolean): Boolean fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) fun navigateToRoomMemberDetails(userId: UserId) fun handlePermalinkClick(data: PermalinkData) @@ -291,7 +291,7 @@ class MessagesNode( } }, onGalleryEventItemClick = { event, index -> - callback.handleGalleryItemClick(event, canUseOverlay, index) + callback.handleGalleryItemClick(event, index, canUseOverlay) }, onUserDataClick = callback::navigateToRoomMemberDetails, onLinkClick = { url, customTab -> diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt index a6181179503..06d97f167c6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt @@ -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 = { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt index bfed13028b1..b4fe504ab17 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt @@ -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 = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt index d9b8150fd23..4d527736918 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt @@ -126,7 +126,7 @@ class ThreadedMessagesNode( interface Callback : Plugin { fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean - fun handleGalleryItemClick(event: TimelineItem.Event, canUseOverlay: Boolean, galleryItemIndex: Int): Boolean + fun handleGalleryItemClick(event: TimelineItem.Event, galleryItemIndex: Int, canUseOverlay: Boolean): Boolean fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) fun navigateToRoomMemberDetails(userId: UserId) fun handlePermalinkClick(data: PermalinkData) @@ -291,7 +291,7 @@ class ThreadedMessagesNode( } == true }, onGalleryEventItemClick = { event, index -> - callback.handleGalleryItemClick(event, canUseOverlay, index) + callback.handleGalleryItemClick(event, index, canUseOverlay) }, onUserDataClick = callback::navigateToRoomMemberDetails, onLinkClick = { url, customTab -> diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index 56deb75a657..a7160226bf3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -97,13 +97,13 @@ 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, onReactionLongClick: (emoji: String, TimelineItem.Event) -> Unit, onMoreReactionsClick: (TimelineItem.Event) -> Unit, onReadReceiptClick: (TimelineItem.Event) -> Unit, - onGalleryItemClick: ((TimelineItem.Event, Int) -> Unit), modifier: Modifier = Modifier, lazyListState: LazyListState = rememberLazyListState(), forceJumpToBottomVisibility: Boolean = false, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt index 45f4a86a534..36deec04684 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt @@ -33,6 +33,7 @@ internal fun ATimelineItemEventRow( isLastOutgoingMessage = isLastOutgoingMessage, displayThreadSummaries = displayThreadSummaries, onEventClick = {}, + onGalleryItemClick = {}, onLongClick = {}, onLinkClick = {}, onLinkLongClick = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index 45d97905fc7..678397c809f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -147,6 +147,7 @@ fun TimelineItemEventRow( isLastOutgoingMessage: Boolean, displayThreadSummaries: Boolean, onEventClick: () -> Unit, + onGalleryItemClick: ((Int) -> Unit), onLongClick: () -> Unit, onLinkClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit, @@ -159,7 +160,6 @@ fun TimelineItemEventRow( onSwipeToReply: () -> Unit, eventSink: (TimelineEvent.TimelineItemEvent) -> Unit, modifier: Modifier = Modifier, - onGalleryItemClick: ((Int) -> Unit)? = null, eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = { contentModifier, onContentLayoutChange -> // Only pass down a custom clickable lambda if the content can be clicked separately val onContentClick = onEventClick.takeUnless { event.isWholeContentClickable } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt index b41e66ae82a..461b29b4720 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt @@ -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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index d4a296ee8cf..6a5cbfd9dd2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -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, @@ -73,7 +74,6 @@ internal fun TimelineItemRow( onSwipeToReply: (TimelineItem.Event) -> Unit, eventSink: (TimelineEvent.TimelineItemEvent) -> Unit, modifier: Modifier = Modifier, - onGalleryItemClick: ((TimelineItem.Event, Int) -> Unit)? = null, eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = { event, contentModifier, onContentLayoutChange -> TimelineItemEventContentView( @@ -81,7 +81,7 @@ internal fun TimelineItemRow( hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine), onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, onContentClick = { onContentClick(event) }, - onGalleryItemClick = { index -> onGalleryItemClick?.invoke(event, index) }, + onGalleryItemClick = { index -> onGalleryItemClick(event, index) }, onLongClick = { onLongClick(event) }, onLinkClick = onLinkClick, onLinkLongClick = onLinkLongClick, @@ -180,7 +180,7 @@ internal fun TimelineItemRow( onMoreReactionsClick = onMoreReactionsClick, onReadReceiptClick = onReadReceiptClick, onSwipeToReply = { onSwipeToReply(timelineItem) }, - onGalleryItemClick = { index -> onGalleryItemClick?.invoke(timelineItem, index) }, + onGalleryItemClick = { index -> onGalleryItemClick(timelineItem, index) }, eventSink = eventSink, eventContentView = { contentModifier, onContentLayoutChange -> eventContentView(timelineItem, contentModifier, onContentLayoutChange) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt index db32b7a524f..a6015758fce 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt @@ -74,6 +74,7 @@ fun TimelineItemStateEventRow( onShowContentClick = {}, eventSink = eventSink, onContentClick = null, + onGalleryItemClick = {}, onLongClick = null, modifier = Modifier.defaultTimelineContentPadding() ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt index 12b4b1ae233..72cd2285d2c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt @@ -42,13 +42,13 @@ fun TimelineItemEventContentView( content: TimelineItemEventContent, hideMediaContent: Boolean, onContentClick: (() -> Unit)?, + onGalleryItemClick: ((Int) -> Unit), onLongClick: (() -> Unit)?, onShowContentClick: () -> Unit, onLinkClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit, eventSink: (TimelineEvent.TimelineItemEvent) -> Unit, modifier: Modifier = Modifier, - onGalleryItemClick: ((Int) -> Unit)? = null, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {}, ) { val presenterFactories = LocalTimelineItemPresenterFactories.current @@ -95,7 +95,7 @@ fun TimelineItemEventContentView( ) is TimelineItemGalleryContent -> TimelineItemGalleryView( content = content, - onContentClick = { index -> onGalleryItemClick?.invoke(index) }, + onContentClick = { index -> onGalleryItemClick(index) }, onLongClick = onLongClick, onLinkClick = onLinkClick, onLinkLongClick = onLinkLongClick, @@ -104,7 +104,7 @@ fun TimelineItemEventContentView( ) is TimelineItemAttachmentsContent -> TimelineItemAttachmentsListView( content = content, - onContentClick = { index -> onGalleryItemClick?.invoke(index) }, + onContentClick = { index -> onGalleryItemClick(index) }, onLinkClick = onLinkClick, onLinkLongClick = onLinkLongClick, onContentLayoutChange = {}, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt index 393dcada184..35e60684a95 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt @@ -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.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.setPinnedMessagesListView( state = state, onBackClick = onBackClick, onEventClick = onEventClick, + onGalleryItemClick = onGalleryItemClick, onUserDataClick = onUserDataClick, onLinkClick = onLinkClick, onLinkLongClick = onLinkLongClick, From 870d4b3b3dc88d974e3d24ea3baa5a31f43f28e5 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 26 Jun 2026 10:22:11 +0200 Subject: [PATCH 49/52] Improve code and fix separator color Closes #7101 --- .../event/TimelineItemAttachmentsListView.kt | 29 +++++-------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt index 07ffca7b884..d0d67b9a784 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt @@ -70,24 +70,26 @@ fun TimelineItemAttachmentsListView( Column(modifier = modifier) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(0.dp), ) { content.attachments.forEachIndexed { index, attachment -> Column { if (index > 0) { - HorizontalDivider() + HorizontalDivider( + color = ElementTheme.colors.borderInteractiveSecondary, + ) } AttachmentListItem( attachment = attachment, onClick = onContentClick?.let { { it(index) } }, - onContentLayoutChange = onContentLayoutChange, ) } } } if (content.showCaption) { - HorizontalDivider() + HorizontalDivider( + color = ElementTheme.colors.borderInteractiveSecondary, + ) Spacer(modifier = Modifier.height(8.dp)) val caption = if (LocalInspectionMode.current) { SpannedString(content.caption) @@ -118,24 +120,21 @@ fun TimelineItemAttachmentsListView( private fun AttachmentListItem( attachment: AttachmentItem, onClick: (() -> Unit)?, - onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, ) { val iconSize = 36.dp val thumbnailSize = 36L val spacing = 8.dp - val hasCaption = false - Row( modifier = modifier - .padding(vertical = 6.dp) .then( if (onClick != null) { Modifier.clickable(onClick = onClick) } else { Modifier } - ), + ) + .padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(spacing), ) { @@ -148,7 +147,6 @@ private fun AttachmentListItem( ) { if (attachment.thumbnailSource != null) { val isVideo = attachment.mimeType.isMimeTypeVideo() - AsyncImage( model = MediaRequestData( source = attachment.thumbnailSource, @@ -160,7 +158,6 @@ private fun AttachmentListItem( .size(iconSize) .clip(RoundedCornerShape(4.dp)), ) - if (isVideo) { Box( modifier = Modifier @@ -183,14 +180,12 @@ private fun AttachmentListItem( 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, @@ -216,14 +211,6 @@ private fun AttachmentListItem( style = ElementTheme.typography.fontBodySmRegular, maxLines = 1, overflow = TextOverflow.Ellipsis, - onTextLayout = if (hasCaption) { - {} - } else { - ContentAvoidingLayout.measureLastTextLine( - onContentLayoutChange = onContentLayoutChange, - extraWidth = iconSize + spacing - ) - }, ) } } From dc3168559e1532c80498c4eba791a7a833a4639d Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 26 Jun 2026 14:46:08 +0200 Subject: [PATCH 50/52] React on attachment item click Improve code --- .../messages/impl/MessagesFlowNode.kt | 112 ++++++++++++------ .../event/TimelineItemAttachmentsListView.kt | 18 +-- .../event/TimelineItemEventContentView.kt | 4 +- .../event/TimelineItemGalleryView.kt | 108 +++++++---------- 4 files changed, 124 insertions(+), 118 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 651523af7b7..da7f3d8d5be 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -48,6 +48,7 @@ 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 @@ -795,43 +796,82 @@ class MessagesFlowNode( galleryItemIndex: Int, canUseOverlay: Boolean, ): Boolean { - if (event.content !is TimelineItemGalleryContent) return false - val galleryInfo = GalleryInfo( - caption = event.content.caption, - senderId = event.senderId, - senderName = event.safeSenderName, - senderAvatar = event.senderAvatar.url, - dateSent = dateFormatter.format( - event.sentTimeMillis, - mode = DateFormatterMode.Day, - ), - dateSentFull = dateFormatter.format( - timestamp = event.sentTimeMillis, - mode = DateFormatterMode.Full, - ), - initialIndex = galleryItemIndex, - ) - val galleryItems = event.content.items.map { galleryItem -> - GalleryItemData( - filename = galleryItem.filename, - mimeType = galleryItem.mimeType, - mediaSource = galleryItem.mediaSource, - thumbnailSource = galleryItem.thumbnailSource, - type = galleryItem.type.toMediaViewerType(), - ) - }.reversed() - val navTarget = NavTarget.GalleryViewer( - eventId = event.eventId, - galleryInfo = galleryInfo, - canUseOverlay = canUseOverlay, - galleryItems = galleryItems, - ) - if (canUseOverlay) { - overlay.show(navTarget) - } else { - backstack.push(navTarget) + val navTarget = when (event.content) { + is TimelineItemGalleryContent -> { + val galleryInfo = GalleryInfo( + caption = event.content.caption, + senderId = event.senderId, + senderName = event.safeSenderName, + senderAvatar = event.senderAvatar.url, + dateSent = dateFormatter.format( + event.sentTimeMillis, + mode = DateFormatterMode.Day, + ), + dateSentFull = dateFormatter.format( + timestamp = event.sentTimeMillis, + mode = DateFormatterMode.Full, + ), + initialIndex = galleryItemIndex, + ) + val galleryItems = event.content.items.map { galleryItem -> + GalleryItemData( + filename = galleryItem.filename, + mimeType = galleryItem.mimeType, + mediaSource = galleryItem.mediaSource, + thumbnailSource = galleryItem.thumbnailSource, + type = galleryItem.type.toMediaViewerType(), + ) + }.reversed() + NavTarget.GalleryViewer( + eventId = event.eventId, + galleryInfo = galleryInfo, + canUseOverlay = canUseOverlay, + galleryItems = galleryItems, + ) + } + is TimelineItemAttachmentsContent -> { + val galleryInfo = GalleryInfo( + caption = event.content.caption, + senderId = event.senderId, + senderName = event.safeSenderName, + senderAvatar = event.senderAvatar.url, + dateSent = dateFormatter.format( + event.sentTimeMillis, + mode = DateFormatterMode.Day, + ), + dateSentFull = dateFormatter.format( + timestamp = event.sentTimeMillis, + mode = DateFormatterMode.Full, + ), + initialIndex = galleryItemIndex, + ) + val galleryItems = event.content.attachments.map { attachment -> + GalleryItemData( + filename = attachment.filename, + mimeType = attachment.mimeType, + mediaSource = attachment.mediaSource, + thumbnailSource = attachment.thumbnailSource, + type = GalleryItemData.Type.File, + ) + }.reversed() + NavTarget.GalleryViewer( + eventId = event.eventId, + galleryInfo = galleryInfo, + canUseOverlay = canUseOverlay, + galleryItems = galleryItems, + ) + } + else -> null + } + if (navTarget != null) { + if (canUseOverlay) { + overlay.show(navTarget) + } else { + backstack.push(navTarget) + } + return true } - return true + return false } private fun buildMediaViewerNavTarget( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt index d0d67b9a784..a3461bf9f32 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt @@ -59,14 +59,13 @@ import io.element.android.wysiwyg.link.Link @Composable fun TimelineItemAttachmentsListView( content: TimelineItemAttachmentsContent, - onContentClick: ((Int) -> Unit)?, + onGalleryItemClick: (Int) -> Unit, onLinkClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, ) { if (content.attachments.isEmpty()) return - Column(modifier = modifier) { Column( modifier = Modifier.fillMaxWidth(), @@ -80,12 +79,11 @@ fun TimelineItemAttachmentsListView( } AttachmentListItem( attachment = attachment, - onClick = onContentClick?.let { { it(index) } }, + onClick = { onGalleryItemClick(index) }, ) } } } - if (content.showCaption) { HorizontalDivider( color = ElementTheme.colors.borderInteractiveSecondary, @@ -119,7 +117,7 @@ fun TimelineItemAttachmentsListView( @Composable private fun AttachmentListItem( attachment: AttachmentItem, - onClick: (() -> Unit)?, + onClick: () -> Unit, modifier: Modifier = Modifier, ) { val iconSize = 36.dp @@ -127,13 +125,7 @@ private fun AttachmentListItem( val spacing = 8.dp Row( modifier = modifier - .then( - if (onClick != null) { - Modifier.clickable(onClick = onClick) - } else { - Modifier - } - ) + .clickable(onClick = onClick) .padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(spacing), @@ -223,7 +215,7 @@ internal fun TimelineItemAttachmentsListViewPreview( ) = ElementPreview { TimelineItemAttachmentsListView( content = content, - onContentClick = {}, + onGalleryItemClick = {}, onLinkClick = {}, onLinkLongClick = {}, onContentLayoutChange = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt index 72cd2285d2c..2951d13d656 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt @@ -95,7 +95,7 @@ fun TimelineItemEventContentView( ) is TimelineItemGalleryContent -> TimelineItemGalleryView( content = content, - onContentClick = { index -> onGalleryItemClick(index) }, + onGalleryItemClick = { index -> onGalleryItemClick(index) }, onLongClick = onLongClick, onLinkClick = onLinkClick, onLinkLongClick = onLinkLongClick, @@ -104,7 +104,7 @@ fun TimelineItemEventContentView( ) is TimelineItemAttachmentsContent -> TimelineItemAttachmentsListView( content = content, - onContentClick = { index -> onGalleryItemClick(index) }, + onGalleryItemClick = { index -> onGalleryItemClick(index) }, onLinkClick = onLinkClick, onLinkLongClick = onLinkLongClick, onContentLayoutChange = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt index 5dd8f1a23fb..77a955f8b8d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt @@ -69,7 +69,7 @@ private val THREE_IMAGE_ROW_HEIGHT = 85.dp @Composable fun TimelineItemGalleryView( content: TimelineItemGalleryContent, - onContentClick: ((Int) -> Unit)?, + onGalleryItemClick: (Int) -> Unit, onLongClick: (() -> Unit)?, onLinkClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit, @@ -77,14 +77,11 @@ fun TimelineItemGalleryView( modifier: Modifier = Modifier, ) { if (content.items.isEmpty()) return - 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), @@ -93,21 +90,21 @@ fun TimelineItemGalleryView( totalItems == 1 -> { SingleItemLayout( item = content.items[0], - onClick = onContentClick?.let { { it(0) } }, + onClick = { onGalleryItemClick(0) }, onLongClick = onLongClick, ) } totalItems == 2 -> { TwoItemLayout( items = content.items.toImmutableList(), - onContentClick = onContentClick, + onItemClick = onGalleryItemClick, onLongClick = onLongClick, ) } totalItems == 3 -> { ThreeItemLayout( items = content.items.toImmutableList(), - onContentClick = onContentClick, + onItemClick = onGalleryItemClick, onLongClick = onLongClick, ) } @@ -116,13 +113,12 @@ fun TimelineItemGalleryView( items = content.items.toImmutableList(), showOverflow = showOverflow, overflowCount = overflowCount, - onContentClick = onContentClick, + onItemClick = onGalleryItemClick, onLongClick = onLongClick, ) } } } - if (content.showCaption) { Spacer(modifier = Modifier.height(8.dp)) val caption = if (LocalInspectionMode.current) { @@ -153,7 +149,7 @@ fun TimelineItemGalleryView( @Composable private fun SingleItemLayout( item: GalleryItem, - onClick: (() -> Unit)?, + onClick: () -> Unit, onLongClick: (() -> Unit)?, ) { GalleryItemCell( @@ -171,7 +167,7 @@ private fun SingleItemLayout( @Composable private fun TwoItemLayout( items: ImmutableList, - onContentClick: ((Int) -> Unit)?, + onItemClick: (Int) -> Unit, onLongClick: (() -> Unit)?, ) { Column( @@ -183,7 +179,7 @@ private fun TwoItemLayout( item = item, isLast = false, remainingCount = 0, - onClick = onContentClick?.let { { it(index) } }, + onClick = { onItemClick(index) }, onLongClick = onLongClick, modifier = Modifier .fillMaxWidth() @@ -196,7 +192,7 @@ private fun TwoItemLayout( @Composable private fun ThreeItemLayout( items: ImmutableList, - onContentClick: ((Int) -> Unit)?, + onItemClick: (Int) -> Unit, onLongClick: (() -> Unit)?, ) { Column( @@ -207,7 +203,7 @@ private fun ThreeItemLayout( item = items[0], isLast = false, remainingCount = 0, - onClick = onContentClick?.let { { it(0) } }, + onClick = { onItemClick(0) }, onLongClick = onLongClick, modifier = Modifier .fillMaxWidth() @@ -217,26 +213,18 @@ private fun ThreeItemLayout( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(GRID_SPACING), ) { - GalleryItemCell( - item = items[1], - isLast = false, - remainingCount = 0, - onClick = onContentClick?.let { { it(1) } }, - onLongClick = onLongClick, - modifier = Modifier - .weight(1f) - .height(TWO_IMAGE_ROW_HEIGHT), - ) - GalleryItemCell( - item = items[2], - isLast = false, - remainingCount = 0, - onClick = onContentClick?.let { { it(2) } }, - onLongClick = onLongClick, - modifier = Modifier - .weight(1f) - .height(TWO_IMAGE_ROW_HEIGHT), - ) + 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), + ) + } } } } @@ -246,7 +234,7 @@ private fun FourPlusItemLayout( items: ImmutableList, showOverflow: Boolean, overflowCount: Int, - onContentClick: ((Int) -> Unit)?, + onItemClick: (Int) -> Unit, onLongClick: (() -> Unit)?, ) { Column( @@ -257,26 +245,18 @@ private fun FourPlusItemLayout( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(GRID_SPACING), ) { - GalleryItemCell( - item = items[0], - isLast = false, - remainingCount = 0, - onClick = onContentClick?.let { { it(0) } }, - onLongClick = onLongClick, - modifier = Modifier - .weight(1f) - .height(TWO_IMAGE_ROW_HEIGHT), - ) - GalleryItemCell( - item = items[1], - isLast = false, - remainingCount = 0, - onClick = onContentClick?.let { { it(1) } }, - onLongClick = onLongClick, - modifier = Modifier - .weight(1f) - .height(TWO_IMAGE_ROW_HEIGHT), - ) + 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(), @@ -292,7 +272,7 @@ private fun FourPlusItemLayout( item = items[itemIndex], isLast = isOverflowItem, remainingCount = if (isOverflowItem) overflowCount else 0, - onClick = onContentClick?.let { { it(itemIndex) } }, + onClick = { onItemClick(itemIndex) }, onLongClick = onLongClick, modifier = Modifier .weight(1f) @@ -309,22 +289,16 @@ private fun GalleryItemCell( item: GalleryItem, isLast: Boolean, remainingCount: Int, - onClick: (() -> Unit)?, + onClick: () -> Unit, onLongClick: (() -> Unit)?, modifier: Modifier = Modifier, ) { Box( modifier = modifier .blurHashBackground(item.blurhash, alpha = 0.9f) - .then( - if (onClick != null) { - Modifier.combinedClickable( - onClick = onClick, - onLongClick = onLongClick, - ) - } else { - Modifier - } + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, ), contentAlignment = Alignment.Center, ) { @@ -410,7 +384,7 @@ internal fun TimelineItemGalleryViewPreview( ) = ElementPreview { TimelineItemGalleryView( content = content, - onContentClick = {}, + onGalleryItemClick = {}, onLongClick = {}, onLinkClick = {}, onLinkLongClick = {}, From da83bb270c4c96c9a1e9409bb214e70eaf77cdff Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 26 Jun 2026 15:24:52 +0200 Subject: [PATCH 51/52] Improve code and support 0 items in gallery. --- .../event/TimelineItemAttachmentsListView.kt | 1 - .../event/TimelineItemGalleryView.kt | 57 ++++++++----------- .../TimelineItemContentMessageFactory.kt | 2 +- .../event/TimelineItemAttachmentsContent.kt | 3 +- .../event/TimelineItemEventContentProvider.kt | 2 +- 5 files changed, 28 insertions(+), 37 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt index a3461bf9f32..e21da90e94d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt @@ -65,7 +65,6 @@ fun TimelineItemAttachmentsListView( onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, ) { - if (content.attachments.isEmpty()) return Column(modifier = modifier) { Column( modifier = Modifier.fillMaxWidth(), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt index 77a955f8b8d..c51055da929 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt @@ -55,7 +55,6 @@ 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 -import kotlinx.collections.immutable.toImmutableList private const val MAX_TILES = 5 private val GALLERY_WIDTH = 264.dp @@ -76,7 +75,6 @@ fun TimelineItemGalleryView( onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, ) { - if (content.items.isEmpty()) return val totalItems = content.items.size val showOverflow = totalItems > MAX_TILES val overflowCount = totalItems - MAX_TILES @@ -86,37 +84,30 @@ fun TimelineItemGalleryView( modifier = containerModifier.width(GALLERY_WIDTH), verticalArrangement = Arrangement.spacedBy(GRID_SPACING), ) { - when { - totalItems == 1 -> { - SingleItemLayout( - item = content.items[0], - onClick = { onGalleryItemClick(0) }, - onLongClick = onLongClick, - ) - } - totalItems == 2 -> { - TwoItemLayout( - items = content.items.toImmutableList(), - onItemClick = onGalleryItemClick, - onLongClick = onLongClick, - ) - } - totalItems == 3 -> { - ThreeItemLayout( - items = content.items.toImmutableList(), - onItemClick = onGalleryItemClick, - onLongClick = onLongClick, - ) - } - totalItems >= 4 -> { - FourPlusItemLayout( - items = content.items.toImmutableList(), - showOverflow = showOverflow, - overflowCount = overflowCount, - onItemClick = onGalleryItemClick, - onLongClick = onLongClick, - ) - } + 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) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt index 8f33c2babd9..1cd911cf356 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt @@ -403,7 +403,7 @@ class TimelineItemContentMessageFactory( caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() }, formattedCaption = formattedCaption, isEdited = content.isEdited, - attachments = attachments, + attachments = attachments.toImmutableList(), ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAttachmentsContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAttachmentsContent.kt index 704e2d2fd14..66d28598a10 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAttachmentsContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAttachmentsContent.kt @@ -8,13 +8,14 @@ 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: List, + val attachments: ImmutableList, ) : TimelineItemEventContent, TimelineItemEventMutableContent { override val type: String = "TimelineItemAttachmentsContent" diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt index 9945e872940..454a1d17574 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt @@ -169,7 +169,7 @@ fun aTimelineItemAttachmentsContent( caption = caption, formattedCaption = null, isEdited = false, - attachments = attachments, + attachments = attachments.toImmutableList(), ) fun anAttachmentItem( From 697a46d74b4fbbf4daf2e08f32686b66f585b843 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 26 Jun 2026 15:49:23 +0200 Subject: [PATCH 52/52] Fix click on attachment item not rendering anything. --- .../android/features/messages/impl/MessagesFlowNode.kt | 2 ++ .../element/android/libraries/mediaviewer/api/MediaInfo.kt | 1 + .../libraries/mediaviewer/impl/viewer/MediaViewerNode.kt | 6 +++++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index da7f3d8d5be..9b8577f8ac4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -812,6 +812,7 @@ class MessagesFlowNode( mode = DateFormatterMode.Full, ), initialIndex = galleryItemIndex, + isAttachment = false, ) val galleryItems = event.content.items.map { galleryItem -> GalleryItemData( @@ -844,6 +845,7 @@ class MessagesFlowNode( mode = DateFormatterMode.Full, ), initialIndex = galleryItemIndex, + isAttachment = true, ) val galleryItems = event.content.attachments.map { attachment -> GalleryItemData( diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt index 6d70747a072..962c73b126e 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt @@ -23,6 +23,7 @@ data class GalleryInfo( val dateSent: String?, val dateSentFull: String?, val initialIndex: Int, + val isAttachment: Boolean, ) : Parcelable @Parcelize diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt index 23f0961af04..d7e4985cbb4 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt @@ -129,7 +129,11 @@ class MediaViewerNode( is MediaViewerEntryPoint.Params.Avatar -> MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media) is MediaViewerEntryPoint.Params.EventGallery -> - MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media) + if (inputs.galleryInfo.isAttachment) { + MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(Timeline.Mode.Media) + } else { + MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media) + } is MediaViewerEntryPoint.Params.RoomMedia -> inputs.mode },