From 87aff40b26e2c1e177e890c59df042e68a58cf1c Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:00:56 +0300 Subject: [PATCH 01/27] Support gallery messages (#6519) * Support gallery messages * Address review * Remove unused function * Fix indent * Add "Gallery" message prefix * Support galleries for image editing * Fix tapping on a tile opens the last item * Fix overflow count * Add caption editing to gallery messages * Use the new gallery string for prefix * Lock sending galleries behind a feature flag * Fix detekts * Fix * Ensure image edition is saved when navigating * Fix sending media broken on edited gallery. * Fix tests * Order imports * Remove unused parameters. * Fix copyright header of new files. * Fix Konsist test * Extract new previews to a dedicated file. * Sync strings * Remove unused import * Update screenshots * Trigger CI * Remove parameters with default value. * More cleanup * Restore sendAsFile behavior. * Improve Preview. * Improve Preview. * Improve Preview. * Fix gallery sending cancel and retry issue * Ensure any previous job is cancelled. * Fix issue in summary message * Gallery feature is disabled by default. * Kotlin convention * Remove useless parenthesis * Update screenshots * Fix test * List -> ImmutableList * Remove useless code. * Render formatted caption for attachment list. * Replace set of Booleans by an enum * Remove unused model for individual caption in a gallery Event. * Fix tests * Fix tests * Rework MediaViewer entry point. And ensure that the clicked image from the gallery is displayed first. * Ensure gallery item can be click in the pinned message list Improve the gallery item click handling code. * Improve code and fix separator color Closes #7101 * React on attachment item click Improve code * Improve code and support 0 items in gallery. * Fix click on attachment item not rendering anything. --------- Co-authored-by: Benoit Marty Co-authored-by: Benoit Marty Co-authored-by: ElementBot --- .../messages/impl/MessagesFlowNode.kt | 197 ++++++++- .../features/messages/impl/MessagesNode.kt | 4 + .../messages/impl/MessagesPresenter.kt | 6 +- .../features/messages/impl/MessagesView.kt | 53 ++- .../impl/actionlist/ActionListPresenter.kt | 12 +- .../impl/actionlist/ActionListView.kt | 8 + .../preview/AttachmentsPreviewEvent.kt | 1 + .../preview/AttachmentsPreviewNode.kt | 5 +- .../preview/AttachmentsPreviewPresenter.kt | 363 +++++++++-------- .../preview/AttachmentsPreviewState.kt | 23 +- .../AttachmentsPreviewStateProvider.kt | 44 +- .../preview/AttachmentsPreviewView.kt | 139 ++++++- .../MessagesViewWithIdentityChangePreview.kt | 1 + .../MessageComposerPresenter.kt | 53 ++- .../pinned/list/PinnedMessagesListNode.kt | 4 + .../pinned/list/PinnedMessagesListView.kt | 10 + .../impl/threads/ThreadedMessagesNode.kt | 4 + .../messages/impl/timeline/TimelineView.kt | 3 + .../TimelineViewMessageShieldPreview.kt | 1 + .../components/ATimelineItemEventRow.kt | 1 + ...eItemEventContentForGalleryViewProvider.kt | 138 +++++++ .../components/TimelineItemEventRow.kt | 10 +- .../TimelineItemEventRowGalleryPreview.kt | 35 ++ .../TimelineItemGroupedEventsRow.kt | 3 + .../timeline/components/TimelineItemRow.kt | 5 +- .../components/TimelineItemStateEventRow.kt | 1 + .../TimelineItemAttachmentsContentProvider.kt | 68 ++++ .../event/TimelineItemAttachmentsListView.kt | 222 ++++++++++ .../event/TimelineItemEventContentView.kt | 20 + .../TimelineItemGalleryContentProvider.kt | 115 ++++++ .../event/TimelineItemGalleryView.kt | 384 ++++++++++++++++++ .../components/event/TimelineItemVideoView.kt | 89 +++- .../TimelineItemContentMessageFactory.kt | 144 +++++++ .../impl/timeline/groups/Groupability.kt | 4 + .../impl/timeline/model/TimelineItem.kt | 4 + .../event/TimelineItemAttachmentsContent.kt | 35 ++ .../model/event/TimelineItemEventContent.kt | 20 +- .../event/TimelineItemEventContentProvider.kt | 81 ++++ .../model/event/TimelineItemGalleryContent.kt | 66 +++ .../impl/timeline/protection/TimelineItem.kt | 4 + .../DefaultMessageSummaryFormatter.kt | 12 + .../messages/impl/MessagesViewTest.kt | 2 + .../AttachmentsPreviewPresenterTest.kt | 150 ++++--- .../impl/attachments/SendActionStateTest.kt | 25 +- ...essageComposerPresenterSlashCommandTest.kt | 3 + .../MessageComposerPresenterTest.kt | 3 + .../pinned/list/PinnedMessagesListViewTest.kt | 3 + .../impl/timeline/TimelineViewTest.kt | 2 + .../impl/src/main/res/values/localazy.xml | 2 +- .../DefaultPinnedMessagesBannerFormatter.kt | 4 + .../impl/DefaultRoomLatestEventFormatter.kt | 4 + ...efaultPinnedMessagesBannerFormatterTest.kt | 4 + .../DefaultRoomLatestEventFormatterTest.kt | 6 + .../libraries/featureflag/api/FeatureFlags.kt | 7 + .../matrix/api/media/GalleryItemInfo.kt | 36 ++ .../libraries/matrix/api/timeline/Timeline.kt | 8 + .../api/timeline/item/event/MessageType.kt | 15 + .../matrix/impl/media/FormattedBody.kt | 24 ++ .../impl/media/GalleryItemInfoMapper.kt | 49 +++ .../media/GalleryMediaUploadHandlerImpl.kt | 34 ++ ...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 | 28 ++ .../mediaupload/impl/DefaultMediaSender.kt | 24 ++ .../mediaupload/test/FakeMediaSender.kt | 10 + .../mediaviewer/api/GalleryItemData.kt | 28 ++ .../libraries/mediaviewer/api/MediaInfo.kt | 18 + .../mediaviewer/api/MediaViewerEntryPoint.kt | 32 +- .../impl/DefaultMediaViewerEntryPoint.kt | 26 +- .../impl/datasource/EventItemFactory.kt | 144 ++++++- .../datasource/TimelineMediaItemsFactory.kt | 79 ++-- .../impl/gallery/root/MediaGalleryFlowNode.kt | 3 +- .../viewer/GalleryMediaGalleryDataSource.kt | 102 +++++ .../impl/viewer/MediaViewerDataSource.kt | 8 +- .../impl/viewer/MediaViewerNode.kt | 83 ++-- .../impl/viewer/MediaViewerPresenter.kt | 57 ++- .../impl/viewer/PagerKeysHandler.kt | 24 +- .../viewer/SingleMediaGalleryDataSource.kt | 31 +- .../impl/DefaultMediaViewerEntryPointTest.kt | 27 +- .../datasource/DefaultEventItemFactoryTest.kt | 16 +- .../impl/viewer/MediaViewerPresenterTest.kt | 33 +- .../SingleMediaGalleryDataSourceTest.kt | 13 +- .../DefaultNotifiableEventResolver.kt | 2 + .../src/main/res/values/localazy.xml | 6 + .../tests/konsist/KonsistClassNameTest.kt | 5 +- .../tests/konsist/KonsistPreviewTest.kt | 1 + ...eeditor_AttachmentImageEditorView_2_en.png | 4 +- ...eview_AttachmentsPreviewGalleryView_en.png | 3 + ...essagesViewWithIdentityChange_Day_0_en.png | 4 +- ...essagesViewWithIdentityChange_Day_2_en.png | 4 +- ...sagesViewWithIdentityChange_Night_1_en.png | 4 +- ...veVerifiedUserSendFailureView_Day_2_en.png | 4 +- ...VerifiedUserSendFailureView_Night_1_en.png | 4 +- ...event_ATimelineItemEventRow_Night_0_en.png | 4 +- ...TimelineImageWithCaptionRow_Night_0_en.png | 4 +- ...melineItemAttachmentsListView_Day_0_en.png | 3 + ...melineItemAttachmentsListView_Day_1_en.png | 3 + ...lineItemAttachmentsListView_Night_0_en.png | 3 + ...lineItemAttachmentsListView_Night_1_en.png | 3 + ...event_TimelineItemGalleryView_Day_0_en.png | 3 + ...event_TimelineItemGalleryView_Day_1_en.png | 3 + ...event_TimelineItemGalleryView_Day_2_en.png | 3 + ...event_TimelineItemGalleryView_Day_3_en.png | 3 + ...event_TimelineItemGalleryView_Day_4_en.png | 3 + ...event_TimelineItemGalleryView_Day_5_en.png | 3 + ...event_TimelineItemGalleryView_Day_6_en.png | 3 + ...event_TimelineItemGalleryView_Day_7_en.png | 3 + ...event_TimelineItemGalleryView_Day_8_en.png | 3 + ...event_TimelineItemGalleryView_Day_9_en.png | 3 + ...ent_TimelineItemGalleryView_Night_0_en.png | 3 + ...ent_TimelineItemGalleryView_Night_1_en.png | 3 + ...ent_TimelineItemGalleryView_Night_2_en.png | 3 + ...ent_TimelineItemGalleryView_Night_3_en.png | 3 + ...ent_TimelineItemGalleryView_Night_4_en.png | 3 + ...ent_TimelineItemGalleryView_Night_5_en.png | 3 + ...ent_TimelineItemGalleryView_Night_6_en.png | 3 + ...ent_TimelineItemGalleryView_Night_7_en.png | 3 + ...ent_TimelineItemGalleryView_Night_8_en.png | 3 + ...ent_TimelineItemGalleryView_Night_9_en.png | 3 + ...s.event_TimelineItemVideoView_Day_0_en.png | 4 +- ...s.event_TimelineItemVideoView_Day_1_en.png | 4 +- ...s.event_TimelineItemVideoView_Day_2_en.png | 4 +- ...s.event_TimelineItemVideoView_Day_3_en.png | 4 +- ...event_TimelineItemVideoView_Night_0_en.png | 4 +- ...event_TimelineItemVideoView_Night_1_en.png | 4 +- ...event_TimelineItemVideoView_Night_2_en.png | 4 +- ...event_TimelineItemVideoView_Night_3_en.png | 4 +- ...t_TimelineVideoWithCaptionRow_Day_0_en.png | 4 +- ...TimelineVideoWithCaptionRow_Night_0_en.png | 4 +- ...lineItemEventRowDisambiguated_Day_0_en.png | 4 +- ...melineItemEventRowWithGallery_Day_0_en.png | 3 + ...melineItemEventRowWithGallery_Day_1_en.png | 3 + ...melineItemEventRowWithGallery_Day_2_en.png | 3 + ...melineItemEventRowWithGallery_Day_3_en.png | 3 + ...melineItemEventRowWithGallery_Day_4_en.png | 3 + ...melineItemEventRowWithGallery_Day_5_en.png | 3 + ...melineItemEventRowWithGallery_Day_6_en.png | 3 + ...melineItemEventRowWithGallery_Day_7_en.png | 3 + ...melineItemEventRowWithGallery_Day_8_en.png | 3 + ...lineItemEventRowWithGallery_Night_0_en.png | 3 + ...lineItemEventRowWithGallery_Night_1_en.png | 3 + ...lineItemEventRowWithGallery_Night_2_en.png | 3 + ...lineItemEventRowWithGallery_Night_3_en.png | 3 + ...lineItemEventRowWithGallery_Night_4_en.png | 3 + ...lineItemEventRowWithGallery_Night_5_en.png | 3 + ...lineItemEventRowWithGallery_Night_6_en.png | 3 + ...lineItemEventRowWithGallery_Night_7_en.png | 3 + ...lineItemEventRowWithGallery_Night_8_en.png | 3 + ...mEventRowWithReplyInformative_Day_0_en.png | 4 +- ...ventRowWithReplyInformative_Night_0_en.png | 4 +- ...ineItemEventRowWithReplyOther_Day_0_en.png | 4 +- ...eItemEventRowWithReplyOther_Night_0_en.png | 4 +- ...TimelineItemEventRowWithReply_Day_0_en.png | 4 +- ...imelineItemEventRowWithReply_Day_10_en.png | 4 +- ...TimelineItemEventRowWithReply_Day_2_en.png | 4 +- ...TimelineItemEventRowWithReply_Day_4_en.png | 4 +- ...TimelineItemEventRowWithReply_Day_6_en.png | 4 +- ...TimelineItemEventRowWithReply_Day_8_en.png | 4 +- ...melineItemEventRowWithReply_Night_0_en.png | 4 +- ...elineItemEventRowWithReply_Night_10_en.png | 4 +- ...melineItemEventRowWithReply_Night_2_en.png | 4 +- ...melineItemEventRowWithReply_Night_4_en.png | 4 +- ...melineItemEventRowWithReply_Night_6_en.png | 4 +- ...melineItemEventRowWithReply_Night_8_en.png | 4 +- ...es.impl.timeline_TimelineView_Day_3_en.png | 4 +- ....impl.timeline_TimelineView_Night_3_en.png | 4 +- ...blockedusers_BlockedUsersView_Day_4_en.png | 4 +- ...ockedusers_BlockedUsersView_Night_5_en.png | 4 +- ...address_JoinRoomByAddressView_Day_0_en.png | 4 +- ...dress_JoinRoomByAddressView_Night_0_en.png | 4 +- ...orizontalFloatingToolbarNoFab_Day_0_en.png | 4 +- ...nts_HorizontalFloatingToolbar_Day_0_en.png | 4 +- 180 files changed, 3463 insertions(+), 630 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/TimelineItemEventRowGalleryPreview.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsContentProvider.kt 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/TimelineItemGalleryContentProvider.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 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 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_TimelineItemAttachmentsListView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Day_1_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_TimelineItemAttachmentsListView_Night_1_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_Day_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_5_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_6_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_7_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_8_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_9_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.event_TimelineItemGalleryView_Night_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_5_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_6_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_7_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_8_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_9_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_Day_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_5_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_6_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_7_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_8_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_5_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_6_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_7_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_8_en.png 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 7bb7ba1d004..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 @@ -47,9 +47,12 @@ import io.element.android.features.messages.impl.threads.list.ThreadsListNode import io.element.android.features.messages.impl.timeline.TimelineController import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.event.GalleryItem +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent @@ -86,6 +89,8 @@ import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache import io.element.android.libraries.matrix.ui.messages.RoomNamesCache +import io.element.android.libraries.mediaviewer.api.GalleryInfo +import io.element.android.libraries.mediaviewer.api.GalleryItemData import io.element.android.libraries.mediaviewer.api.MediaInfo import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater @@ -153,7 +158,15 @@ class MessagesFlowNode( ) : NavTarget @Parcelize - data class AttachmentPreview(val timelineMode: Timeline.Mode, val attachment: Attachment, val inReplyToEventId: EventId?) : NavTarget + data class GalleryViewer( + val eventId: EventId?, + val galleryInfo: GalleryInfo, + val canUseOverlay: Boolean, + val galleryItems: List = emptyList(), + ) : NavTarget + + @Parcelize + data class AttachmentPreview(val timelineMode: Timeline.Mode, val attachments: ImmutableList, val inReplyToEventId: EventId?) : NavTarget @Parcelize data class LocationViewer(val mode: ShowLocationMode) : NavTarget @@ -239,7 +252,11 @@ class MessagesFlowNode( callback.navigateToRoomDetails() } - override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean { + override fun handleEventClick( + timelineMode: Timeline.Mode, + event: TimelineItem.Event, + canUseOverlay: Boolean, + ): Boolean { return processEventClick( timelineMode = timelineMode, event = event, @@ -247,10 +264,22 @@ class MessagesFlowNode( ) } + override fun handleGalleryItemClick( + event: TimelineItem.Event, + galleryItemIndex: Int, + canUseOverlay: Boolean, + ): Boolean { + return processGalleryEventClick( + event = event, + canUseOverlay = canUseOverlay, + galleryItemIndex = galleryItemIndex, + ) + } + override fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) { backstack.push( NavTarget.AttachmentPreview( - attachment = attachments.first(), + attachments = attachments, timelineMode = Timeline.Mode.Live, inReplyToEventId = inReplyToEventId, ) @@ -339,13 +368,43 @@ 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 { override fun onDone() { @@ -374,7 +433,7 @@ class MessagesFlowNode( } is NavTarget.AttachmentPreview -> { val inputs = AttachmentsPreviewNode.Inputs( - attachment = navTarget.attachment, + attachments = navTarget.attachments, timelineMode = navTarget.timelineMode, inReplyToEventId = navTarget.inReplyToEventId, ) @@ -455,6 +514,18 @@ class MessagesFlowNode( ) } + override fun handleGalleryItemClick( + event: TimelineItem.Event, + galleryItemIndex: Int, + canUseOverlay: Boolean, + ) { + processGalleryEventClick( + event = event, + galleryItemIndex = galleryItemIndex, + canUseOverlay = canUseOverlay, + ) + } + override fun navigateToRoomMemberDetails(userId: UserId) { callback.navigateToRoomMemberDetails(userId) } @@ -490,7 +561,11 @@ class MessagesFlowNode( focusedEventId = navTarget.focusedEventId, ) val callback = object : ThreadedMessagesNode.Callback { - override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean { + override fun handleEventClick( + timelineMode: Timeline.Mode, + event: TimelineItem.Event, + canUseOverlay: Boolean, + ): Boolean { return processEventClick( timelineMode = timelineMode, event = event, @@ -498,10 +573,22 @@ class MessagesFlowNode( ) } + override fun handleGalleryItemClick( + event: TimelineItem.Event, + galleryItemIndex: Int, + canUseOverlay: Boolean, + ): Boolean { + return processGalleryEventClick( + event = event, + canUseOverlay = canUseOverlay, + galleryItemIndex = galleryItemIndex, + ) + } + override fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) { backstack.push( NavTarget.AttachmentPreview( - attachment = attachments.first(), + attachments = attachments, timelineMode = Timeline.Mode.Thread(navTarget.threadRootId), inReplyToEventId = inReplyToEventId, ) @@ -704,6 +791,91 @@ class MessagesFlowNode( } } + private fun processGalleryEventClick( + event: TimelineItem.Event, + galleryItemIndex: Int, + canUseOverlay: Boolean, + ): Boolean { + val navTarget = when (event.content) { + is TimelineItemGalleryContent -> { + val galleryInfo = GalleryInfo( + caption = event.content.caption, + senderId = event.senderId, + senderName = event.safeSenderName, + senderAvatar = event.senderAvatar.url, + dateSent = dateFormatter.format( + event.sentTimeMillis, + mode = DateFormatterMode.Day, + ), + dateSentFull = dateFormatter.format( + timestamp = event.sentTimeMillis, + mode = DateFormatterMode.Full, + ), + initialIndex = galleryItemIndex, + isAttachment = false, + ) + val galleryItems = event.content.items.map { galleryItem -> + GalleryItemData( + filename = galleryItem.filename, + mimeType = galleryItem.mimeType, + mediaSource = galleryItem.mediaSource, + thumbnailSource = galleryItem.thumbnailSource, + type = galleryItem.type.toMediaViewerType(), + ) + }.reversed() + NavTarget.GalleryViewer( + eventId = event.eventId, + galleryInfo = galleryInfo, + canUseOverlay = canUseOverlay, + galleryItems = galleryItems, + ) + } + is TimelineItemAttachmentsContent -> { + val galleryInfo = GalleryInfo( + caption = event.content.caption, + senderId = event.senderId, + senderName = event.safeSenderName, + senderAvatar = event.senderAvatar.url, + dateSent = dateFormatter.format( + event.sentTimeMillis, + mode = DateFormatterMode.Day, + ), + dateSentFull = dateFormatter.format( + timestamp = event.sentTimeMillis, + mode = DateFormatterMode.Full, + ), + initialIndex = galleryItemIndex, + isAttachment = true, + ) + val galleryItems = event.content.attachments.map { attachment -> + GalleryItemData( + filename = attachment.filename, + mimeType = attachment.mimeType, + mediaSource = attachment.mediaSource, + thumbnailSource = attachment.thumbnailSource, + type = GalleryItemData.Type.File, + ) + }.reversed() + NavTarget.GalleryViewer( + eventId = event.eventId, + galleryInfo = galleryInfo, + canUseOverlay = canUseOverlay, + galleryItems = galleryItems, + ) + } + else -> null + } + if (navTarget != null) { + if (canUseOverlay) { + overlay.show(navTarget) + } else { + backstack.push(navTarget) + } + return true + } + return false + } + private fun buildMediaViewerNavTarget( mode: MediaViewerEntryPoint.MediaViewerMode, event: TimelineItem.Event, @@ -768,3 +940,12 @@ class MessagesFlowNode( } } } + +private fun GalleryItem.Type.toMediaViewerType(): GalleryItemData.Type { + return when (this) { + GalleryItem.Type.Image -> GalleryItemData.Type.Image + GalleryItem.Type.Video -> GalleryItemData.Type.Video + GalleryItem.Type.Audio -> GalleryItemData.Type.Audio + GalleryItem.Type.File -> GalleryItemData.Type.File + } +} 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 47516a6cdea..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,6 +119,7 @@ class MessagesNode( interface Callback : Plugin { fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean + fun handleGalleryItemClick(event: TimelineItem.Event, galleryItemIndex: Int, canUseOverlay: Boolean): Boolean fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) fun navigateToRoomMemberDetails(userId: UserId) fun handlePermalinkClick(data: PermalinkData) @@ -289,6 +290,9 @@ class MessagesNode( } } }, + onGalleryEventItemClick = { event, index -> + callback.handleGalleryItemClick(event, index, canUseOverlay) + }, onUserDataClick = callback::navigateToRoomMemberDetails, onLinkClick = { url, customTab -> onLinkClick( 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..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,10 +47,10 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo -import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent +import io.element.android.features.messages.impl.timeline.model.event.captionOrNull import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter import io.element.android.features.roomcall.api.RoomCallState @@ -533,7 +533,7 @@ class MessagesPresenter( ) { val composerMode = MessageComposerMode.EditCaption( eventOrTransactionId = targetEvent.eventOrTransactionId, - content = (targetEvent.content as? TimelineItemEventContentWithAttachment)?.caption.orEmpty(), + content = targetEvent.content.captionOrNull().orEmpty(), ) composerState.eventSink( MessageComposerEvent.SetMode(composerMode) @@ -606,7 +606,7 @@ class MessagesPresenter( } private fun handleCopyCaption(event: TimelineItem.Event) { - val content = (event.content as? TimelineItemEventContentWithAttachment)?.caption ?: return + val content = event.content.captionOrNull() ?: return clipboardHelper.copyPlainText(content) if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) 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 b299ea42a97..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, @@ -207,15 +208,15 @@ fun MessagesView( val expandableState = rememberExpandableBottomSheetLayoutState() ExpandableBottomSheetLayout( modifier = modifier - .fillMaxSize() - .imePadding() - .systemBarsPadding() - .onSizeChanged { size -> - // Let the composer takes at max half of the available height. - // The value will be different if the soft keyboard is displayed - // or not. - maxComposerHeightPx = (size.height * 0.5f).toInt() - }, + .fillMaxSize() + .imePadding() + .systemBarsPadding() + .onSizeChanged { size -> + // Let the composer takes at max half of the available height. + // The value will be different if the soft keyboard is displayed + // or not. + maxComposerHeightPx = (size.height * 0.5f).toInt() + }, content = { Scaffold( contentWindowInsets = WindowInsets.statusBars, @@ -252,12 +253,18 @@ fun MessagesView( content = { padding -> Box( modifier = Modifier - .padding(padding) - .consumeWindowInsets(padding) + .padding(padding) + .consumeWindowInsets(padding) ) { MessagesViewContent( state = state, onContentClick = ::onContentClick, + onGalleryItemClick = { event, index -> + val hideKeyboard = onGalleryEventItemClick(event, index) + if (hideKeyboard) { + localView.hideKeyboard() + } + }, onMessageLongClick = ::onMessageLongClick, onUserDataClick = { hidingKeyboard { @@ -290,10 +297,10 @@ fun MessagesView( SuggestionsPickerView( modifier = Modifier - .shadow(10.dp) - .background(ElementTheme.colors.bgCanvasDefault) - .align(Alignment.BottomStart) - .heightIn(max = 230.dp), + .shadow(10.dp) + .background(ElementTheme.colors.bgCanvasDefault) + .align(Alignment.BottomStart) + .heightIn(max = 230.dp), roomId = state.roomId, roomName = state.roomName, roomAvatarData = state.roomAvatar, @@ -459,6 +466,7 @@ private fun MessagesViewContent( onMoreReactionsClick: (TimelineItem.Event) -> Unit, onReadReceiptClick: (TimelineItem.Event) -> Unit, onMessageLongClick: (TimelineItem.Event) -> Unit, + onGalleryItemClick: ((TimelineItem.Event, Int) -> Unit), onSendLocationClick: () -> Unit, onCreatePollClick: () -> Unit, onViewAllPinnedMessagesClick: () -> Unit, @@ -469,9 +477,9 @@ private fun MessagesViewContent( ) { Box( modifier = modifier - .fillMaxSize() - .navigationBarsPadding() - .imePadding(), + .fillMaxSize() + .navigationBarsPadding() + .imePadding(), ) { AttachmentsBottomSheet( state = state.composerState, @@ -510,6 +518,7 @@ private fun MessagesViewContent( onUserDataClick = onUserDataClick, onLinkClick = { link -> onLinkClick(link, false) }, onContentClick = onContentClick, + onGalleryItemClick = onGalleryItemClick, onMessageLongClick = onMessageLongClick, onSwipeToReply = onSwipeToReply, onReactionClick = onReactionClick, @@ -598,9 +607,9 @@ private fun MessagesViewComposerBottomSheetContents( private fun CantSendMessageBanner() { Row( modifier = Modifier - .fillMaxWidth() - .background(ElementTheme.colors.bgSubtleSecondary) - .padding(16.dp), + .fillMaxWidth() + .background(ElementTheme.colors.bgSubtleSecondary) + .padding(16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { @@ -646,6 +655,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class) forceJumpToBottomVisibility = true, knockRequestsBannerView = {}, onThreadsListClick = {}, + onGalleryEventItemClick = { _, _ -> false }, ) } @@ -699,6 +709,7 @@ internal fun MessagesViewA11yPreview() = ElementPreview { onJoinCallClick = {}, onViewAllPinnedMessagesClick = {}, onThreadsListClick = {}, + onGalleryEventItemClick = { _, _ -> false }, forceJumpToBottomVisibility = true, knockRequestsBannerView = {}, ) 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..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,10 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent @@ -37,6 +39,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.canBeCopied import io.element.android.features.messages.impl.timeline.model.event.canBeForwarded import io.element.android.features.messages.impl.timeline.model.event.canReact +import io.element.android.features.messages.impl.timeline.model.event.captionOrNull import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.api.DateFormatterMode @@ -198,9 +201,12 @@ class DefaultActionListPresenter( add(TimelineItemAction.Forward) } if (timelineItem.isEditable && usersEventPermissions.canSendMessage) { - if (timelineItem.content is TimelineItemEventContentWithAttachment) { + if (timelineItem.content is TimelineItemEventContentWithAttachment || + timelineItem.content is TimelineItemGalleryContent || + timelineItem.content is TimelineItemAttachmentsContent) { // Caption - if (timelineItem.content.caption == null) { + val caption = timelineItem.content.captionOrNull() + if (caption == null) { add(TimelineItemAction.AddCaption) } else { add(TimelineItemAction.EditCaption) @@ -225,7 +231,7 @@ class DefaultActionListPresenter( } if (timelineItem.content.canBeCopied()) { add(TimelineItemAction.CopyText) - } else if ((timelineItem.content as? TimelineItemEventContentWithAttachment)?.caption.isNullOrBlank().not()) { + } else if (timelineItem.content.captionOrNull().isNullOrBlank().not()) { add(TimelineItemAction.CopyCaption) } if (timelineItem.isRemote) { 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 c2d97dcf80a..87e3b5695b9 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 fb9fddbb486..7ef14f26ff7 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 @@ -23,4 +23,5 @@ sealed interface AttachmentsPreviewEvent { data object ResetImageEdits : AttachmentsPreviewEvent data class UpdateImageCropRect(val cropRect: NormalizedCropRect) : AttachmentsPreviewEvent data object ClearImageEditError : AttachmentsPreviewEvent + data class SetCurrentCarouselIndex(val index: Int) : AttachmentsPreviewEvent } 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 0f60d7ff0ea..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 @@ -12,6 +12,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -26,8 +27,6 @@ import io.element.android.features.messages.impl.attachments.preview.imageeditor import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEditorState import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEdits import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorPresenter -import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState -import io.element.android.features.messages.impl.attachments.video.VideoCompressionPresetSelector import io.element.android.libraries.androidutils.file.TemporaryUriDeleter import io.element.android.libraries.androidutils.file.safeDelete import io.element.android.libraries.androidutils.hash.hash @@ -48,10 +47,11 @@ import io.element.android.libraries.mediaupload.api.allFiles import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber @@ -59,7 +59,7 @@ import java.io.File @AssistedInject class AttachmentsPreviewPresenter( - @Assisted private val attachment: Attachment, + @Assisted private val attachments: ImmutableList, @Assisted private val onDoneListener: OnDoneListener, @Assisted private val timelineMode: Timeline.Mode, @Assisted private val inReplyToEventId: EventId?, @@ -68,7 +68,6 @@ class AttachmentsPreviewPresenter( private val temporaryUriDeleter: TemporaryUriDeleter, private val attachmentImageEditor: AttachmentImageEditor, private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory, - private val videoCompressionPresetSelector: VideoCompressionPresetSelector, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, private val dispatchers: CoroutineDispatchers, private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, @@ -76,13 +75,18 @@ class AttachmentsPreviewPresenter( @AssistedFactory interface Factory { fun create( - attachment: Attachment, + attachments: ImmutableList, timelineMode: Timeline.Mode, onDoneListener: OnDoneListener, inReplyToEventId: EventId?, ): AttachmentsPreviewPresenter } + data class AttachmentAndEdits( + val attachment: Attachment, + val edits: AttachmentImageEdits, + ) + private val mediaSender = mediaSenderFactory.create(timelineMode) @Composable @@ -92,14 +96,13 @@ class AttachmentsPreviewPresenter( val sendActionState = remember { mutableStateOf(SendActionState.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()) } 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( @@ -108,13 +111,23 @@ class AttachmentsPreviewPresenter( val ongoingSendAttachmentJob = remember { mutableStateOf(null) } + var currentIndex by remember { mutableIntStateOf(0) } + + var attachmentsAndEdits by remember { + mutableStateOf( + attachments.map { + AttachmentAndEdits(it, AttachmentImageEdits()) + } + ) + } + 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()) @@ -123,52 +136,51 @@ class AttachmentsPreviewPresenter( var displayFileTooLargeError by remember { mutableStateOf(false) } - LaunchedEffect( - mediaOptimizationSelectorState.displayMediaSelectorViews, - mediaOptimizationSelectorState.videoSizeEstimations, - currentAttachment, - imageEditorState, - isApplyingImageEdits, - ) { - // If the media optimization selector is not displayed, we can pre-process the media - // to prepare it for sending. This is done to avoid blocking the UI thread when the - // user clicks on the send button. - @Suppress("ComplexCondition") + LaunchedEffect(mediaOptimizationSelectorState.displayMediaSelectorViews, mediaOptimizationSelectorState.selectedVideoPreset) { if (mediaOptimizationSelectorState.displayMediaSelectorViews == false && - preprocessMediaJob == null && imageEditorState == null && - !isApplyingImageEdits) { - if (mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo() && mediaOptimizationSelectorState.videoSizeEstimations.dataOrNull() == null) { - Timber.d("Waiting for video size estimations to be able to select the best video compression preset before pre-processing the media") - return@LaunchedEffect - } - val config = getAutoPreprocessMediaOptimizationConfig( - mediaAttachment = mediaAttachment, - mediaOptimizationSelectorState = mediaOptimizationSelectorState, - ) ?: return@LaunchedEffect - preprocessMediaJob = coroutineScope.preProcessAttachment( - attachment = currentAttachment, - mediaOptimizationConfig = config, - displayProgress = false, - sendActionState = sendActionState, + !isApplyingImageEdits + ) { + val config = MediaOptimizationConfig( + compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled ?: mediaOptimizationConfigProvider.get().compressImages, + videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: mediaOptimizationConfigProvider.get().videoCompressionPreset, ) + preprocessMediaJob?.cancel() + preprocessMediaJob = coroutineScope.launch(dispatchers.io) { + preProcessAttachments( + attachments = attachmentsAndEdits.map { it.attachment }, + mediaOptimizationConfig = config, + displayProgress = false, + sendActionState = sendActionState, + ) + } } } - LaunchedEffect(originalLocalMedia) { - canEditImage = originalLocalMedia.info.canEditImage() || attachmentImageEditor.canEdit(originalLocalMedia) + LaunchedEffect(currentIndex) { + val currentMedia = (attachments.getOrNull(currentIndex) as? Attachment.Media)?.localMedia + if (currentMedia != null) { + canEditImage = currentMedia.info.canEditImage() || attachmentImageEditor.canEdit(currentMedia) + } } val maxUploadSize = mediaOptimizationSelectorState.maxUploadSize.dataOrNull() LaunchedEffect(maxUploadSize) { - // Check file upload size if the media won't be processed for upload - val isImageFile = mediaAttachment.localMedia.info.isImageAttachment() - val isVideoFile = mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo() - if (maxUploadSize != null && !(isImageFile || isVideoFile)) { + if (maxUploadSize != null) { // If file size is not known, we're permissive and allow sending. The SDK will cancel the upload if needed. - val fileSize = mediaAttachment.localMedia.info.fileSize ?: 0L - if (maxUploadSize < fileSize) { - displayFileTooLargeError = true + displayFileTooLargeError = attachments.any { attachment -> + when (attachment) { + is Attachment.Media -> { + val isImageFile = attachment.localMedia.info.isImageAttachment() + val isVideoFile = attachment.localMedia.info.mimeType.isMimeTypeVideo() + if (isImageFile || isVideoFile) { + false + } else { + val fileSize = attachment.localMedia.info.fileSize ?: 0L + maxUploadSize < fileSize + } + } + } } } } @@ -192,12 +204,29 @@ class AttachmentsPreviewPresenter( compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled == true, videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: VideoCompressionPreset.STANDARD, ) - preprocessMediaJob = preProcessAttachment( - attachment = currentAttachment, - mediaOptimizationConfig = config, - displayProgress = true, - sendActionState = sendActionState, + preprocessMediaJob = coroutineScope.launch(dispatchers.io) { + preProcessAttachments( + attachments = attachmentsAndEdits.map { it.attachment }, + mediaOptimizationConfig = config, + displayProgress = true, + sendActionState = sendActionState, + ) + } + } else if (preprocessMediaJob?.isActive != true && sendActionState.value !is SendActionState.Sending.ReadyToUpload) { + val config = MediaOptimizationConfig( + compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled + ?: mediaOptimizationConfigProvider.get().compressImages, + videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset + ?: mediaOptimizationConfigProvider.get().videoCompressionPreset, ) + preprocessMediaJob = coroutineScope.launch(dispatchers.io) { + preProcessAttachments( + attachments = attachmentsAndEdits.map { it.attachment }, + mediaOptimizationConfig = config, + displayProgress = true, + sendActionState = sendActionState, + ) + } } // If the processing was hidden before, make it visible now @@ -206,35 +235,27 @@ class AttachmentsPreviewPresenter( } // Wait until the media is ready to be uploaded - val mediaUploadInfo = observableSendState.firstInstanceOf().mediaInfo + val allMediaUploadInfos = observableSendState.firstInstanceOf().mediaInfos // Pre-processing is done, send the attachment val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder) .takeIf { it.isNotEmpty() } - val editedTempFileToDelete = editedTempFile - editedTempFile = null - - // If we're supposed to send the media as a background job, we can dismiss this screen already - if (coroutineContext.isActive) { - onDoneListener() - } + val editedTempFilesToDelete = editedTempFiles + editedTempFiles = emptyMap() // Send the media using the session coroutine scope so it doesn't matter if this screen or the chat one are closed sessionCoroutineScope.launch(dispatchers.io) { - try { - sendPreProcessedMedia( - mediaUploadInfo = mediaUploadInfo, - caption = caption, - sendActionState = sendActionState, - dismissAfterSend = false, - inReplyToEventId = inReplyToEventId, - ) - } finally { - editedTempFileToDelete?.safeDelete() - // Clean up the pre-processed media after it's been sent - mediaSender.cleanUp() - } + sendGalleryPreProcessed( + mediaUploadInfos = allMediaUploadInfos, + caption = caption, + sendActionState = sendActionState, + inReplyToEventId = inReplyToEventId, + ) + + // Clean up the pre-processed media after it's been sent + mediaSender.cleanUp() + editedTempFilesToDelete.values.forEach { it.safeDelete() } } } } @@ -251,7 +272,11 @@ class AttachmentsPreviewPresenter( ongoingSendAttachmentJob.value?.cancel() // Dismiss the screen - dismiss(sendActionState, editedTempFile) + dismiss( + attachments = attachmentsAndEdits.map { it.attachment }, + sendActionState = sendActionState, + editedTempFiles = editedTempFiles, + ) } AttachmentsPreviewEvent.CancelAndClearSendState -> { // Cancel media sending @@ -260,22 +285,23 @@ class AttachmentsPreviewPresenter( ongoingSendAttachmentJob.value = null } - val mediaUploadInfo = sendActionState.value.mediaUploadInfo() - sendActionState.value = if (mediaUploadInfo != null) { - SendActionState.Sending.ReadyToUpload(mediaUploadInfo) + val mediaUploadInfoList = sendActionState.value.mediaUploadInfoList() + sendActionState.value = if (mediaUploadInfoList != null) { + SendActionState.Sending.ReadyToUpload(mediaUploadInfoList) } else { SendActionState.Idle } } AttachmentsPreviewEvent.OpenImageEditor -> { - val resolvedCanEditImage = canEditImage || originalLocalMedia.info.canEditImage() + val currentLocalMedia = (attachments.getOrNull(currentIndex) as? Attachment.Media)?.localMedia ?: return + val resolvedCanEditImage = canEditImage || currentLocalMedia.info.canEditImage() if (resolvedCanEditImage) { preprocessMediaJob?.cancel() preprocessMediaJob = null resetPreparedMedia(sendActionState) imageEditorState = AttachmentImageEditorState( - localMedia = originalLocalMedia, - edits = appliedImageEdits, + localMedia = currentLocalMedia, + edits = attachmentsAndEdits.get(currentIndex).edits, previewDebug = false, ) } @@ -315,10 +341,15 @@ class AttachmentsPreviewPresenter( AttachmentsPreviewEvent.ApplyImageEdits -> { val pendingState = imageEditorState ?: return if (!pendingState.edits.hasChanges) { - editedTempFile?.safeDelete() - editedTempFile = null - appliedImageEdits = pendingState.edits - currentAttachment = Attachment.Media(originalLocalMedia) + editedTempFiles[currentIndex]?.safeDelete() + editedTempFiles = editedTempFiles - currentIndex + currentAttachment = attachmentsAndEdits[currentIndex].attachment + attachmentsAndEdits = attachmentsAndEdits.toMutableList().also { + it[currentIndex] = AttachmentAndEdits( + currentAttachment, + pendingState.edits, + ) + }.toImmutableList() imageEditorState = null resetPreparedMedia(sendActionState) return @@ -328,16 +359,21 @@ class AttachmentsPreviewPresenter( coroutineScope.launch { val result = withContext(dispatchers.io) { attachmentImageEditor.exportEdits( - localMedia = originalLocalMedia, + localMedia = pendingState.localMedia, edits = pendingState.edits, ) } result.fold( onSuccess = { editedMedia -> - editedTempFile?.safeDelete() - editedTempFile = editedMedia.file - appliedImageEdits = pendingState.edits + editedTempFiles[currentIndex]?.safeDelete() + editedTempFiles = editedTempFiles + (currentIndex to editedMedia.file) currentAttachment = Attachment.Media(editedMedia.localMedia) + attachmentsAndEdits = attachmentsAndEdits.toMutableList().also { + it[currentIndex] = AttachmentAndEdits( + currentAttachment, + pendingState.edits, + ) + }.toImmutableList() imageEditorState = null resetPreparedMedia(sendActionState) }, @@ -352,11 +388,14 @@ class AttachmentsPreviewPresenter( AttachmentsPreviewEvent.ClearImageEditError -> { displayImageEditError = false } + is AttachmentsPreviewEvent.SetCurrentCarouselIndex -> { + currentIndex = event.index + } } } return AttachmentsPreviewState( - attachment = currentAttachment, + attachments = attachmentsAndEdits.map { it.attachment }.toImmutableList(), imageEditorState = imageEditorState, canEditImage = canEditImage, isApplyingImageEdits = isApplyingImageEdits, @@ -365,92 +404,62 @@ class AttachmentsPreviewPresenter( textEditorState = textEditorState, mediaOptimizationSelectorState = mediaOptimizationSelectorState, displayFileTooLargeError = displayFileTooLargeError, + currentIndex = currentIndex, eventSink = ::handleEvent, ) } - private suspend fun getAutoPreprocessMediaOptimizationConfig( - mediaAttachment: Attachment.Media, - mediaOptimizationSelectorState: MediaOptimizationSelectorState, - ): MediaOptimizationConfig? { - return if (mediaAttachment.sendAsFile) { - // If we're sending the media as a file, we can skip image compression and we should select the highest video compression preset that still fits - // the upload limit (if the estimations are available) - val videoCompressionPreset = videoCompressionPresetSelector.selectBestVideoPreset( - expectedVideoPreset = VideoCompressionPreset.HIGH, - videoSizeEstimations = mediaOptimizationSelectorState.videoSizeEstimations, - ).dataOrNull() ?: VideoCompressionPreset.HIGH - - MediaOptimizationConfig( - compressImages = false, - videoCompressionPreset = videoCompressionPreset, - ) - } else { - // Otherwise, we just rely on the user preferences for media optimization - mediaOptimizationConfigProvider.get() - } - } - - private fun CoroutineScope.preProcessAttachment( - attachment: Attachment, - mediaOptimizationConfig: MediaOptimizationConfig, - displayProgress: Boolean, - sendActionState: MutableState, - ) = 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, emptyList()) + return + } + } + ) } } - ) + } + sendActionState.value = SendActionState.Sending.ReadyToUpload(mediaUploadInfos) } private fun dismiss( + attachments: List, sendActionState: MutableState, - editedTempFile: File?, + editedTempFiles: Map = emptyMap(), ) { - // 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) } } } - editedTempFile?.safeDelete() - // Reset the sendActionState to ensure that dialog is closed before the screen + val uploadInfos = (sendActionState.value as? SendActionState.Sending.ReadyToUpload)?.mediaInfos + uploadInfos?.forEach { cleanUp(it) } + editedTempFiles.values.forEach { it.safeDelete() } sendActionState.value = SendActionState.Done onDoneListener() } @@ -464,41 +473,45 @@ class AttachmentsPreviewPresenter( } private fun resetPreparedMedia(sendActionState: MutableState) { - sendActionState.value.mediaUploadInfo()?.let(::cleanUp) + sendActionState.value.mediaUploadInfoList()?.forEach(::cleanUp) mediaSender.cleanUp() sendActionState.value = SendActionState.Idle } - private suspend fun sendPreProcessedMedia( - mediaUploadInfo: MediaUploadInfo, + private suspend fun sendGalleryPreProcessed( + mediaUploadInfos: List, 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) + 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) } } ) 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..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 @@ -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,17 +40,17 @@ sealed interface SendActionState { @Immutable sealed interface Sending : SendActionState { data class Processing(val displayProgress: Boolean) : Sending - data class ReadyToUpload(val mediaInfo: MediaUploadInfo) : Sending - data class Uploading(val mediaUploadInfo: MediaUploadInfo) : Sending + data class ReadyToUpload(val mediaInfos: List) : 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 -> mediaInfo - 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 a2df440a12d..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 @@ -41,9 +41,9 @@ 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(2000.milliseconds) + isPillVisible = false + } + } + } + + androidx.compose.animation.AnimatedVisibility( + visible = isPillVisible, + enter = fadeIn(animationSpec = tween(150)), + exit = fadeOut(animationSpec = tween(300)), + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 8.dp), + ) { + GalleryCarouselPill( + currentIndex = pagerState.currentPage + 1, + totalCount = state.totalCount, + ) + } + + LaunchedEffect(pagerState.currentPage) { + state.eventSink(AttachmentsPreviewEvent.SetCurrentCarouselIndex(pagerState.currentPage)) + } + } else { + val firstAttachment = state.attachments.first() + when (firstAttachment) { + is Attachment.Media -> { + localMediaRenderer.Render(firstAttachment.localMedia) + } } } } - val mediaInfo = (state.attachment as? Attachment.Media)?.localMedia?.info + val mediaInfo = (state.attachments.first() as? Attachment.Media)?.localMedia?.info if (mediaInfo?.isImageAttachment() == true) { ImageOptimizationSelector(state.mediaOptimizationSelectorState) } else if (mediaInfo?.mimeType?.isMimeTypeVideo() == true) { @@ -485,16 +571,16 @@ private fun AttachmentsPreviewBottomActions( internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = ElementPreviewDark { AttachmentsPreviewView( state = state, - localMediaRenderer = object : LocalMediaRenderer { - @Composable - override fun Render(localMedia: LocalMedia) { - Image( - painter = painterResource(id = CommonDrawables.sample_background), - modifier = Modifier.fillMaxSize(), - contentDescription = null, - ) - } - } + localMediaRenderer = SingleItemPreviewRenderer, + ) +} + +@Preview +@Composable +internal fun AttachmentsPreviewGalleryViewPreview() = ElementPreviewDark { + AttachmentsPreviewView( + state = anAttachmentsPreviewGalleryState(), + localMediaRenderer = SingleItemPreviewRenderer, ) } @@ -537,3 +623,24 @@ fun VideoCompressionPreset.subtitle(): String { } ) } + +@Composable +internal fun GalleryCarouselPill( + currentIndex: Int, + totalCount: Int, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(16.dp), + color = ElementTheme.colors.floatingDateBadgeBackground, + shadowElevation = 4.dp, + ) { + Text( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + text = stringResource(R.string.screen_media_upload_preview_item_count, currentIndex, totalCount), + style = ElementTheme.typography.fontBodyMdMedium, + color = ElementTheme.colors.textPrimary, + ) + } +} 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/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 4b9ee28e35f..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 @@ -34,7 +34,6 @@ import im.vector.app.features.analytics.plan.Interaction import io.element.android.features.location.api.LocationService import io.element.android.features.messages.impl.MessagesNavigator import io.element.android.features.messages.impl.attachments.Attachment -import io.element.android.features.messages.impl.attachments.Attachment.Media import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError import io.element.android.features.messages.impl.draft.ComposerDraftService import io.element.android.features.messages.impl.messagecomposer.suggestions.RoomAliasSuggestionsDataSource @@ -48,6 +47,8 @@ import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.UserId @@ -133,6 +134,7 @@ class MessageComposerPresenter( private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, private val notificationConversationService: NotificationConversationService, private val slashCommandService: SlashCommandService, + private val featureFlagService: FeatureFlagService, ) : Presenter { @AssistedFactory interface Factory { @@ -177,10 +179,19 @@ class MessageComposerPresenter( canShareLocation.value = locationService.isServiceAvailable() } + val isSendGalleryMessagesEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.SendGalleryMessages) + .collectAsState(initial = false) + val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType -> handlePickedMedia(uri, mimeType) } - val filesPicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri, mimeType -> + val galleryMultiMediaPicker = mediaPickerProvider.registerGalleryMultiPicker { uris -> + handlePickedMediaList(uris) + } + val filesPicker = mediaPickerProvider.registerFileMultiPicker(AnyMimeTypes) { uris -> + handlePickedMediaList(uris, sendAsFile = true) + } + val fileSinglePicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri, mimeType -> handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream, sendAsFile = true) } val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri -> @@ -266,7 +277,7 @@ class MessageComposerPresenter( is MessageComposerEvent.SendUri -> { val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId sessionCoroutineScope.sendAttachment( - attachment = Media( + attachment = Attachment.Media( localMedia = localMediaFactory.createFromUri( uri = event.uri, mimeType = null, @@ -289,11 +300,19 @@ class MessageComposerPresenter( MessageComposerEvent.DismissAttachmentMenu -> showAttachmentSourcePicker = false MessageComposerEvent.PickAttachmentSource.FromGallery -> localCoroutineScope.launch { showAttachmentSourcePicker = false - galleryMediaPicker.launch() + if (isSendGalleryMessagesEnabled) { + galleryMultiMediaPicker.launch() + } else { + galleryMediaPicker.launch() + } } MessageComposerEvent.PickAttachmentSource.FromFiles -> localCoroutineScope.launch { showAttachmentSourcePicker = false - filesPicker.launch() + if (isSendGalleryMessagesEnabled) { + filesPicker.launch() + } else { + fileSinglePicker.launch() + } } MessageComposerEvent.PickAttachmentSource.PhotoFromCamera -> localCoroutineScope.launch { showAttachmentSourcePicker = false @@ -623,6 +642,30 @@ class MessageComposerPresenter( messageComposerContext.composerMode = MessageComposerMode.Normal } + private fun handlePickedMediaList( + uris: List, + sendAsFile: Boolean = false, + ) { + if (uris.isEmpty()) return + if (uris.size == 1) { + handlePickedMedia(uris.first(), sendAsFile = sendAsFile) + return + } + val attachments = uris.map { uri -> + val localMedia = localMediaFactory.createFromUri( + uri = uri, + mimeType = null, + name = null, + formattedFileSize = null, + ) + Attachment.Media(localMedia, sendAsFile = sendAsFile) + }.toImmutableList() + val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId + navigator.navigateToPreviewAttachments(attachments, inReplyToEventId) + + messageComposerContext.composerMode = MessageComposerMode.Normal + } + private suspend fun sendMedia( uri: Uri, mimeType: String, 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 f476e1f4b8e..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,6 +126,7 @@ class ThreadedMessagesNode( interface Callback : Plugin { fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean + fun handleGalleryItemClick(event: TimelineItem.Event, galleryItemIndex: Int, canUseOverlay: Boolean): Boolean fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) fun navigateToRoomMemberDetails(userId: UserId) fun handlePermalinkClick(data: PermalinkData) @@ -289,6 +290,9 @@ class ThreadedMessagesNode( } } == true }, + onGalleryEventItemClick = { event, index -> + callback.handleGalleryItemClick(event, index, canUseOverlay) + }, onUserDataClick = callback::navigateToRoomMemberDetails, onLinkClick = { url, customTab -> onLinkClick( 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 0a306aea1c4..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,6 +97,7 @@ fun TimelineView( onUserDataClick: (MatrixUser) -> Unit, onLinkClick: (Link) -> Unit, onContentClick: (TimelineItem.Event) -> Unit, + onGalleryItemClick: ((TimelineItem.Event, Int) -> Unit), onMessageLongClick: (TimelineItem.Event) -> Unit, onSwipeToReply: (TimelineItem.Event) -> Unit, onReactionClick: (emoji: String, TimelineItem.Event) -> Unit, @@ -178,6 +179,7 @@ fun TimelineView( onLinkClick = onLinkClick, onLinkLongClick = ::onLinkLongClick, onContentClick = onContentClick, + onGalleryItemClick = onGalleryItemClick, onLongClick = onMessageLongClick, inReplyToClick = ::inReplyToClick, onReactionClick = onReactionClick, @@ -428,6 +430,7 @@ internal fun TimelineViewPreview( onReactionLongClick = { _, _ -> }, onMoreReactionsClick = {}, onReadReceiptClick = {}, + onGalleryItemClick = { _, _ -> }, forceJumpToBottomVisibility = true, ) } 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/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/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..63034d56e12 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventContentForGalleryViewProvider.kt @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.messages.impl.timeline.model.event.GalleryItem +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent +import io.element.android.features.messages.impl.timeline.model.event.aGalleryItem +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAttachmentsContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemGalleryContent +import io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem +import kotlin.time.Duration + +class TimelineItemEventContentForGalleryViewProvider : + PreviewParameterProvider { + 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( + type = GalleryItem.Type.Video, + duration = Duration.parse("PT1M30S") + ), + aGalleryItem( + type = GalleryItem.Type.Video, + duration = Duration.parse("PT45S") + ), + aGalleryItem(), + ), + ), + aTimelineItemAttachmentsContent( + caption = "Documents", + attachments = listOf( + anAttachmentItem( + filename = "document.pdf", + fileExtension = "pdf", + ), + anAttachmentItem( + filename = "presentation.pdf", + fileExtension = "pdf", + ), + anAttachmentItem( + filename = "spreadsheet.xlsx", + fileExtension = "xlsx", + ), + ), + ), + aTimelineItemAttachmentsContent( + caption = "Photos", + attachments = listOf( + anAttachmentItem( + filename = "photo1.jpg", + fileExtension = "jpg", + hasThumbnail = true, + ), + anAttachmentItem( + filename = "photo2.jpg", + fileExtension = "jpg", + hasThumbnail = true, + ), + anAttachmentItem( + filename = "photo3.jpg", + fileExtension = "jpg", + hasThumbnail = true, + ), + ), + ), + aTimelineItemAttachmentsContent( + caption = "Videos", + attachments = listOf( + anAttachmentItem( + filename = "video1.mp4", + fileExtension = "mp4", + hasThumbnail = true, + fileSize = 150_000_000L, + formattedFileSize = "150MB", + ), + anAttachmentItem( + filename = "video2.mov", + fileExtension = "mov", + hasThumbnail = true, + fileSize = 85_000_000L, + formattedFileSize = "85MB", + ), + ), + ), + aTimelineItemAttachmentsContent( + caption = "Audio", + attachments = listOf( + anAttachmentItem( + filename = "recording.mp3", + fileExtension = "mp3", + fileSize = 4_500_000L, + formattedFileSize = "4.5MB", + ), + anAttachmentItem( + filename = "voice_message.m4a", + fileExtension = "m4a", + fileSize = 1_200_000L, + formattedFileSize = "1.2MB", + ), + ), + ), + ) +} 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 1f7f127be70..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 @@ -70,6 +70,8 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent @@ -145,6 +147,7 @@ fun TimelineItemEventRow( isLastOutgoingMessage: Boolean, displayThreadSummaries: Boolean, onEventClick: () -> Unit, + onGalleryItemClick: ((Int) -> Unit), onLongClick: () -> Unit, onLinkClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit, @@ -165,13 +168,14 @@ fun TimelineItemEventRow( content = event.content, hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId), onContentClick = onContentClick, + onGalleryItemClick = onGalleryItemClick, onLongClick = onLongClick, onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, onLinkClick = onLinkClick, onLinkLongClick = onLinkLongClick, eventSink = eventSink, modifier = contentModifier, - onContentLayoutChange = onContentLayoutChange + onContentLayoutChange = onContentLayoutChange, ) }, ) { @@ -776,6 +780,8 @@ private fun MessageEventBubbleContent( val timestampPosition = when (val content = event.content) { is TimelineItemImageContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay is TimelineItemVideoContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay + is TimelineItemGalleryContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Below + is TimelineItemAttachmentsContent -> TimestampPosition.Below is TimelineItemStickerContent -> TimestampPosition.Overlay is TimelineItemLocationContent -> { val content = content.ensureActiveLiveLocation() @@ -790,6 +796,8 @@ private fun MessageEventBubbleContent( val paddingBehaviour = when (event.content) { is TimelineItemImageContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media is TimelineItemVideoContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media + is TimelineItemGalleryContent -> ContentPadding.CaptionedMedia + is TimelineItemAttachmentsContent -> ContentPadding.CaptionedMedia is TimelineItemStickerContent, is TimelineItemLocationContent -> ContentPadding.Media else -> ContentPadding.Textual 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..22c3f354f25 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowGalleryPreview.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithGalleryPreview( + @PreviewParameter(TimelineItemEventContentForGalleryViewProvider::class) content: TimelineItemEventContent, +) = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = content, + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + } +} 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 0229d2220b3..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, @@ -80,12 +81,13 @@ internal fun TimelineItemRow( hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine), onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, onContentClick = { onContentClick(event) }, + onGalleryItemClick = { index -> onGalleryItemClick(event, index) }, onLongClick = { onLongClick(event) }, onLinkClick = onLinkClick, onLinkLongClick = onLinkLongClick, eventSink = eventSink, modifier = contentModifier, - onContentLayoutChange = onContentLayoutChange + onContentLayoutChange = onContentLayoutChange, ) }, ) { @@ -178,6 +180,7 @@ internal fun TimelineItemRow( onMoreReactionsClick = onMoreReactionsClick, onReadReceiptClick = onReadReceiptClick, onSwipeToReply = { onSwipeToReply(timelineItem) }, + onGalleryItemClick = { index -> onGalleryItemClick(timelineItem, index) }, eventSink = eventSink, eventContentView = { contentModifier, onContentLayoutChange -> eventContentView(timelineItem, contentModifier, onContentLayoutChange) 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/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..032229da828 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsContentProvider.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.event + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAttachmentsContent +import io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem +import io.element.android.libraries.matrix.api.media.MediaSource + +class TimelineItemAttachmentsContentProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aTimelineItemAttachmentsContent( + body = "Files", + caption = null, + attachments = listOf( + anAttachmentItem( + filename = "document.pdf", + mimeType = "application/pdf", + fileSize = null, + formattedFileSize = "2.5 MB", + fileExtension = "PDF", + ), + anAttachmentItem( + filename = "photo.jpg", + mimeType = "image/jpeg", + thumbnailSource = MediaSource(url = "thumb", json = ""), + fileSize = null, + formattedFileSize = "1.2 MB", + fileExtension = "JPG", + ), + anAttachmentItem( + filename = "spreadsheet.xlsx", + mimeType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + fileSize = null, + formattedFileSize = "450 KB", + fileExtension = "XLSX", + ), + ), + ), + aTimelineItemAttachmentsContent( + body = "Files", + caption = "Important documents", + attachments = listOf( + anAttachmentItem( + filename = "report.pdf", + mimeType = "application/pdf", + fileSize = null, + formattedFileSize = "3.2 MB", + fileExtension = "PDF", + ), + anAttachmentItem( + filename = "notes.txt", + mimeType = "text/plain", + fileSize = null, + formattedFileSize = "12 KB", + fileExtension = "TXT", + ), + ), + ), + ) +} 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..e21da90e94d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt @@ -0,0 +1,222 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.event + +import android.text.SpannedString +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData +import io.element.android.features.messages.impl.timeline.model.event.AttachmentItem +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.wysiwyg.compose.EditorStyledText +import io.element.android.wysiwyg.link.Link + +@Composable +fun TimelineItemAttachmentsListView( + content: TimelineItemAttachmentsContent, + onGalleryItemClick: (Int) -> Unit, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + content.attachments.forEachIndexed { index, attachment -> + Column { + if (index > 0) { + HorizontalDivider( + color = ElementTheme.colors.borderInteractiveSecondary, + ) + } + AttachmentListItem( + attachment = attachment, + onClick = { onGalleryItemClick(index) }, + ) + } + } + } + if (content.showCaption) { + HorizontalDivider( + color = ElementTheme.colors.borderInteractiveSecondary, + ) + Spacer(modifier = Modifier.height(8.dp)) + val caption = if (LocalInspectionMode.current) { + SpannedString(content.caption) + } else { + content.formattedCaption ?: SpannedString(content.caption) + } + CompositionLocalProvider( + LocalContentColor provides ElementTheme.colors.textPrimary, + LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular + ) { + EditorStyledText( + modifier = Modifier + .padding(horizontal = 4.dp) + .widthIn(min = 120.dp), + text = caption, + style = ElementRichTextEditorStyle.textStyle(), + onLinkClickedListener = onLinkClick, + onLinkLongClickedListener = onLinkLongClick, + releaseOnDetach = false, + onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange), + ) + } + } + } +} + +@Composable +private fun AttachmentListItem( + attachment: AttachmentItem, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val iconSize = 36.dp + val thumbnailSize = 36L + val spacing = 8.dp + Row( + modifier = modifier + .clickable(onClick = onClick) + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing), + ) { + Box( + modifier = Modifier + .size(iconSize) + .clip(RoundedCornerShape(4.dp)) + .background(ElementTheme.colors.bgCanvasDefault), + contentAlignment = Alignment.Center, + ) { + if (attachment.thumbnailSource != null) { + val isVideo = attachment.mimeType.isMimeTypeVideo() + AsyncImage( + model = MediaRequestData( + source = attachment.thumbnailSource, + kind = MediaRequestData.Kind.Thumbnail(thumbnailSize), + ), + contentDescription = attachment.filename, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(iconSize) + .clip(RoundedCornerShape(4.dp)), + ) + if (isVideo) { + Box( + modifier = Modifier + .size(iconSize) + .background( + color = Color.Black.copy(alpha = 0.3f), + shape = RoundedCornerShape(4.dp) + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = CompoundIcons.VideoCallSolid(), + contentDescription = stringResource(CommonStrings.common_video), + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + } + } + } else { + val isImage = attachment.mimeType.isMimeTypeImage() + val isVideo = attachment.mimeType.isMimeTypeVideo() + val isAudio = attachment.mimeType.isMimeTypeAudio() + val icon = when { + isImage -> CompoundIcons.Image() + isVideo -> CompoundIcons.VideoCall() + isAudio -> CompoundIcons.Audio() + else -> CompoundIcons.Attachment() + } + Icon( + imageVector = icon, + contentDescription = null, + tint = ElementTheme.colors.iconPrimary, + modifier = Modifier.size(24.dp), + ) + } + } + + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = attachment.filename, + color = ElementTheme.colors.textPrimary, + style = ElementTheme.typography.fontBodyLgRegular, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "${attachment.fileExtension} • ${attachment.formattedFileSize}", + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodySmRegular, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemAttachmentsListViewPreview( + @PreviewParameter(TimelineItemAttachmentsContentProvider::class) content: TimelineItemAttachmentsContent +) = ElementPreview { + TimelineItemAttachmentsListView( + content = content, + onGalleryItemClick = {}, + onLinkClick = {}, + onLinkLongClick = {}, + onContentLayoutChange = {}, + ) +} 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..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 @@ -14,10 +14,12 @@ import io.element.android.features.messages.impl.timeline.TimelineEvent import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.rememberPresenter +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent @@ -40,6 +42,7 @@ fun TimelineItemEventContentView( content: TimelineItemEventContent, hideMediaContent: Boolean, onContentClick: (() -> Unit)?, + onGalleryItemClick: ((Int) -> Unit), onLongClick: (() -> Unit)?, onShowContentClick: () -> Unit, onLinkClick: (Link) -> Unit, @@ -90,6 +93,23 @@ fun TimelineItemEventContentView( onContentLayoutChange = onContentLayoutChange, modifier = modifier, ) + is TimelineItemGalleryContent -> TimelineItemGalleryView( + content = content, + onGalleryItemClick = { index -> onGalleryItemClick(index) }, + onLongClick = onLongClick, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, + onContentLayoutChange = onContentLayoutChange, + modifier = modifier, + ) + is TimelineItemAttachmentsContent -> TimelineItemAttachmentsListView( + content = content, + onGalleryItemClick = { index -> onGalleryItemClick(index) }, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, + onContentLayoutChange = {}, + modifier = modifier, + ) is TimelineItemStickerContent -> TimelineItemStickerView( content = content, hideMediaContent = hideMediaContent, 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..2a0c10f518f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryContentProvider.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.event + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.messages.impl.timeline.model.event.GalleryItem +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemGalleryContent +import io.element.android.libraries.matrix.api.media.MediaSource +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +class TimelineItemGalleryContentProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aTimelineItemGalleryContent( + caption = "My vacation photos", + items = listOf( + aGalleryItem(), + aGalleryItem(type = GalleryItem.Type.Video, duration = 65.seconds), + aGalleryItem(), + aGalleryItem(), + ), + ), + aTimelineItemGalleryContent( + items = listOf( + aGalleryItem(), + ), + ), + aTimelineItemGalleryContent( + items = listOf( + aGalleryItem(width = 1920, height = 1080), + aGalleryItem(width = 1600, height = 900), + ), + ), + aTimelineItemGalleryContent( + items = listOf( + aGalleryItem(width = 1080, height = 1920), + aGalleryItem(width = 900, height = 1600), + ), + ), + aTimelineItemGalleryContent( + items = listOf( + aGalleryItem(width = 1920, height = 1080), + aGalleryItem(width = 1080, height = 1920), + ), + ), + aTimelineItemGalleryContent( + items = listOf( + aGalleryItem(type = GalleryItem.Type.Video, duration = 45.seconds), + aGalleryItem(), + aGalleryItem(), + ), + ), + aTimelineItemGalleryContent( + caption = "2 landscape + 1 portrait", + items = listOf( + aGalleryItem(width = 1920, height = 1080), + aGalleryItem(width = 1600, height = 900), + aGalleryItem(width = 1080, height = 1920), + ), + ), + aTimelineItemGalleryContent( + caption = "1 landscape + 2 portrait", + items = listOf( + aGalleryItem(width = 1920, height = 1080), + aGalleryItem(width = 1080, height = 1920), + aGalleryItem(width = 900, height = 1600), + ), + ), + aTimelineItemGalleryContent( + items = listOf( + aGalleryItem(), + aGalleryItem(), + aGalleryItem(type = GalleryItem.Type.Video, duration = 120.seconds), + aGalleryItem(), + aGalleryItem(), + ), + ), + aTimelineItemGalleryContent( + caption = "Many photos", + items = (1..12).map { + aGalleryItem( + type = if (it == 3) GalleryItem.Type.Video else GalleryItem.Type.Image, + ) + }, + ), + ) +} + +private fun aGalleryItem( + type: GalleryItem.Type = GalleryItem.Type.Image, + width: Int = 400, + height: Int = 300, + duration: Duration = Duration.ZERO, +): GalleryItem { + return GalleryItem( + filename = "photo.jpg", + mimeType = "image/jpeg", + mediaSource = MediaSource(url = "", json = ""), + thumbnailSource = null, + width = width, + height = height, + thumbnailWidth = width, + thumbnailHeight = height, + blurhash = null, + type = type, + duration = duration, + ) +} 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..c51055da929 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt @@ -0,0 +1,384 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.event + +import android.text.SpannedString +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData +import io.element.android.features.messages.impl.timeline.model.event.GalleryItem +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent +import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.bgSubtleTertiary +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle +import io.element.android.libraries.ui.utils.time.formatShort +import io.element.android.wysiwyg.compose.EditorStyledText +import io.element.android.wysiwyg.link.Link +import kotlinx.collections.immutable.ImmutableList + +private const val MAX_TILES = 5 +private val GALLERY_WIDTH = 264.dp +private val GRID_SPACING = 4.dp +private val GROUP_CORNER_RADIUS = 6.dp + +private val SINGLE_IMAGE_HEIGHT = 130.dp +private val TWO_IMAGE_ROW_HEIGHT = 130.dp +private val THREE_IMAGE_ROW_HEIGHT = 85.dp + +@Composable +fun TimelineItemGalleryView( + content: TimelineItemGalleryContent, + onGalleryItemClick: (Int) -> Unit, + onLongClick: (() -> Unit)?, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, + modifier: Modifier = Modifier, +) { + val totalItems = content.items.size + val showOverflow = totalItems > MAX_TILES + val overflowCount = totalItems - MAX_TILES + Column(modifier = modifier) { + val containerModifier = Modifier.clip(RoundedCornerShape(GROUP_CORNER_RADIUS)) + Column( + modifier = containerModifier.width(GALLERY_WIDTH), + verticalArrangement = Arrangement.spacedBy(GRID_SPACING), + ) { + when (totalItems) { + 0 -> Unit + 1 -> SingleItemLayout( + item = content.items[0], + onClick = { onGalleryItemClick(0) }, + onLongClick = onLongClick, + ) + 2 -> TwoItemLayout( + items = content.items, + onItemClick = onGalleryItemClick, + onLongClick = onLongClick, + ) + 3 -> ThreeItemLayout( + items = content.items, + onItemClick = onGalleryItemClick, + onLongClick = onLongClick, + ) + else -> FourPlusItemLayout( + items = content.items, + showOverflow = showOverflow, + overflowCount = overflowCount, + onItemClick = onGalleryItemClick, + onLongClick = onLongClick, + ) + } + } + if (content.showCaption) { + Spacer(modifier = Modifier.height(8.dp)) + val caption = if (LocalInspectionMode.current) { + SpannedString(content.caption) + } else { + content.formattedCaption ?: SpannedString(content.caption) + } + CompositionLocalProvider( + LocalContentColor provides ElementTheme.colors.textPrimary, + LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular + ) { + EditorStyledText( + modifier = Modifier + .padding(horizontal = 4.dp) + .widthIn(min = 120.dp), + text = caption, + style = ElementRichTextEditorStyle.textStyle(), + onLinkClickedListener = onLinkClick, + onLinkLongClickedListener = onLinkLongClick, + releaseOnDetach = false, + onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange), + ) + } + } + } +} + +@Composable +private fun SingleItemLayout( + item: GalleryItem, + onClick: () -> Unit, + onLongClick: (() -> Unit)?, +) { + GalleryItemCell( + item = item, + isLast = false, + remainingCount = 0, + onClick = onClick, + onLongClick = onLongClick, + modifier = Modifier + .width(GALLERY_WIDTH) + .height(SINGLE_IMAGE_HEIGHT), + ) +} + +@Composable +private fun TwoItemLayout( + items: ImmutableList, + onItemClick: (Int) -> Unit, + onLongClick: (() -> Unit)?, +) { + Column( + modifier = Modifier.width(GALLERY_WIDTH), + verticalArrangement = Arrangement.spacedBy(GRID_SPACING), + ) { + items.forEachIndexed { index, item -> + GalleryItemCell( + item = item, + isLast = false, + remainingCount = 0, + onClick = { onItemClick(index) }, + onLongClick = onLongClick, + modifier = Modifier + .fillMaxWidth() + .height(TWO_IMAGE_ROW_HEIGHT), + ) + } + } +} + +@Composable +private fun ThreeItemLayout( + items: ImmutableList, + onItemClick: (Int) -> Unit, + onLongClick: (() -> Unit)?, +) { + Column( + modifier = Modifier.width(GALLERY_WIDTH), + verticalArrangement = Arrangement.spacedBy(GRID_SPACING), + ) { + GalleryItemCell( + item = items[0], + isLast = false, + remainingCount = 0, + onClick = { onItemClick(0) }, + onLongClick = onLongClick, + modifier = Modifier + .fillMaxWidth() + .height(SINGLE_IMAGE_HEIGHT), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(GRID_SPACING), + ) { + for (it in 1..2) { + GalleryItemCell( + item = items[it], + isLast = false, + remainingCount = 0, + onClick = { onItemClick(it) }, + onLongClick = onLongClick, + modifier = Modifier + .weight(1f) + .height(TWO_IMAGE_ROW_HEIGHT), + ) + } + } + } +} + +@Composable +private fun FourPlusItemLayout( + items: ImmutableList, + showOverflow: Boolean, + overflowCount: Int, + onItemClick: (Int) -> Unit, + onLongClick: (() -> Unit)?, +) { + Column( + modifier = Modifier.width(GALLERY_WIDTH), + verticalArrangement = Arrangement.spacedBy(GRID_SPACING), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(GRID_SPACING), + ) { + for (it in 0..1) { + GalleryItemCell( + item = items[it], + isLast = false, + remainingCount = 0, + onClick = { onItemClick(it) }, + onLongClick = onLongClick, + modifier = Modifier + .weight(1f) + .height(TWO_IMAGE_ROW_HEIGHT), + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(GRID_SPACING), + ) { + val bottomRowItems = if (showOverflow) 3 else minOf(items.size - 2, 3) + val bottomRowHeight = if (bottomRowItems == 3) THREE_IMAGE_ROW_HEIGHT else TWO_IMAGE_ROW_HEIGHT + for (i in 0 until bottomRowItems) { + val itemIndex = 2 + i + if (itemIndex < items.size) { + val isOverflowItem = showOverflow && i == bottomRowItems - 1 + GalleryItemCell( + item = items[itemIndex], + isLast = isOverflowItem, + remainingCount = if (isOverflowItem) overflowCount else 0, + onClick = { onItemClick(itemIndex) }, + onLongClick = onLongClick, + modifier = Modifier + .weight(1f) + .height(bottomRowHeight), + ) + } + } + } + } +} + +@Composable +private fun GalleryItemCell( + item: GalleryItem, + isLast: Boolean, + remainingCount: Int, + onClick: () -> Unit, + onLongClick: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .blurHashBackground(item.blurhash, alpha = 0.9f) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + ), + contentAlignment = Alignment.Center, + ) { + AsyncImage( + modifier = Modifier.fillMaxSize(), + model = item.thumbnailMediaRequestData, + contentScale = ContentScale.Crop, + alignment = Alignment.Center, + contentDescription = item.filename, + ) + + if (item.type == GalleryItem.Type.Video) { + VideoOverlay(duration = item.duration) + } + + if (isLast && remainingCount > 0) { + RemainingCountOverlay(count = remainingCount) + } + } +} + +@Composable +private fun VideoOverlay(duration: kotlin.time.Duration) { + val gradientColor = ElementTheme.colors.bgCanvasDefault + + Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(80.dp) + .align(Alignment.BottomCenter) + .background( + brush = Brush.verticalGradient( + colors = listOf(gradientColor.copy(alpha = 0f), gradientColor.copy(alpha = 1f)) + ) + ) + ) + Row( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = CompoundIcons.VideoCallSolid(), + contentDescription = null, + tint = ElementTheme.colors.textPrimary, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = duration.formatShort(), + color = ElementTheme.colors.textPrimary, + style = ElementTheme.typography.fontBodySmMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun RemainingCountOverlay(count: Int) { + Box( + modifier = Modifier + .fillMaxSize() + .background(ElementTheme.colors.bgSubtleTertiary.copy(alpha = 0.7f)), + contentAlignment = Alignment.Center, + ) { + Text( + text = "+$count", + color = Color.White, + style = ElementTheme.typography.fontHeadingSmMedium, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemGalleryViewPreview( + @PreviewParameter(TimelineItemGalleryContentProvider::class) content: TimelineItemGalleryContent, +) = ElementPreview { + TimelineItemGalleryView( + content = content, + onGalleryItemClick = {}, + onLongClick = {}, + onLinkClick = {}, + onLinkLongClick = {}, + onContentLayoutChange = {}, + ) +} 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..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 @@ -14,10 +14,14 @@ import androidx.core.text.toSpannable import dev.zacsweers.metro.Inject import io.element.android.features.location.api.Location import io.element.android.features.messages.api.timeline.HtmlConverterProvider +import io.element.android.features.messages.impl.timeline.model.event.AttachmentItem +import io.element.android.features.messages.impl.timeline.model.event.GalleryItem +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent @@ -35,6 +39,8 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.GalleryItemType +import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent @@ -263,6 +269,144 @@ class TimelineItemContentMessageFactory( isEdited = content.isEdited, ) } + is GalleryMessageType -> { + val dom = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser) + val formattedCaption = dom?.let(::parseHtml) + ?: messageType.body.withLinks() + val galleryItems = messageType.items.mapNotNull { item -> + when (item) { + is GalleryItemType.Image -> { + GalleryItem( + filename = item.content.filename, + mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream, + mediaSource = item.content.source, + type = GalleryItem.Type.Image, + thumbnailSource = item.content.info?.thumbnailSource, + width = item.content.info?.width?.toInt(), + height = item.content.info?.height?.toInt(), + thumbnailWidth = item.content.info?.thumbnailInfo?.width?.toInt(), + thumbnailHeight = item.content.info?.thumbnailInfo?.height?.toInt(), + blurhash = item.content.info?.blurhash, + ) + } + is GalleryItemType.Video -> { + GalleryItem( + filename = item.content.filename, + mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream, + mediaSource = item.content.source, + type = GalleryItem.Type.Video, + thumbnailSource = item.content.info?.thumbnailSource, + width = item.content.info?.width?.toInt(), + height = item.content.info?.height?.toInt(), + thumbnailWidth = item.content.info?.thumbnailInfo?.width?.toInt(), + thumbnailHeight = item.content.info?.thumbnailInfo?.height?.toInt(), + blurhash = item.content.info?.blurhash, + duration = item.content.info?.duration ?: Duration.ZERO, + ) + } + is GalleryItemType.Audio -> { + GalleryItem( + filename = item.content.filename, + mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream, + mediaSource = item.content.source, + type = GalleryItem.Type.Audio, + thumbnailSource = null, + width = null, + height = null, + thumbnailWidth = null, + thumbnailHeight = null, + blurhash = null, + duration = item.content.info?.duration ?: Duration.ZERO, + ) + } + is GalleryItemType.File -> { + GalleryItem( + filename = item.content.filename, + mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream, + mediaSource = item.content.source, + type = GalleryItem.Type.File, + thumbnailSource = item.content.info?.thumbnailSource, + width = null, + height = null, + thumbnailWidth = null, + thumbnailHeight = null, + blurhash = null, + ) + } + is GalleryItemType.Other -> null + } + } + val hasPreviews = galleryItems.any { it.thumbnailSource != null } + val isMediaGallery = galleryItems.all { item -> + item.type.isMedia() + } + if (isMediaGallery && hasPreviews) { + TimelineItemGalleryContent( + body = messageType.body, + caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() }, + formattedCaption = formattedCaption, + isEdited = content.isEdited, + items = galleryItems.toImmutableList(), + ) + } else { + val attachments = messageType.items.mapNotNull { item -> + when (item) { + is GalleryItemType.File -> { + AttachmentItem( + filename = item.content.filename, + mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream, + mediaSource = item.content.source, + thumbnailSource = item.content.info?.thumbnailSource, + fileSize = item.content.info?.size, + formattedFileSize = fileSizeFormatter.format(item.content.info?.size ?: 0L), + fileExtension = fileExtensionExtractor.extractFromName(item.content.filename), + ) + } + is GalleryItemType.Image -> { + AttachmentItem( + filename = item.content.filename, + mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream, + mediaSource = item.content.source, + thumbnailSource = item.content.info?.thumbnailSource, + fileSize = item.content.info?.size, + formattedFileSize = fileSizeFormatter.format(item.content.info?.size ?: 0L), + fileExtension = fileExtensionExtractor.extractFromName(item.content.filename), + ) + } + is GalleryItemType.Video -> { + AttachmentItem( + filename = item.content.filename, + mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream, + mediaSource = item.content.source, + thumbnailSource = item.content.info?.thumbnailSource, + fileSize = item.content.info?.size, + formattedFileSize = fileSizeFormatter.format(item.content.info?.size ?: 0L), + fileExtension = fileExtensionExtractor.extractFromName(item.content.filename), + ) + } + is GalleryItemType.Audio -> { + AttachmentItem( + filename = item.content.filename, + mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream, + mediaSource = item.content.source, + thumbnailSource = null, + fileSize = item.content.info?.size, + formattedFileSize = fileSizeFormatter.format(item.content.info?.size ?: 0L), + fileExtension = fileExtensionExtractor.extractFromName(item.content.filename), + ) + } + is GalleryItemType.Other -> null + } + } + TimelineItemAttachmentsContent( + body = messageType.body, + caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() }, + formattedCaption = formattedCaption, + isEdited = content.isEdited, + attachments = attachments.toImmutableList(), + ) + } + } is OtherMessageType -> { val body = messageType.body.trimEnd() TimelineItemTextContent( 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..66d28598a10 --- /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) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model.event + +import io.element.android.libraries.matrix.api.media.MediaSource +import kotlinx.collections.immutable.ImmutableList + +data class TimelineItemAttachmentsContent( + val body: String, + val caption: String?, + val formattedCaption: CharSequence?, + override val isEdited: Boolean, + val attachments: ImmutableList, +) : 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..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 @@ -56,7 +56,9 @@ fun TimelineItemEventContent.canBeForwarded(): Boolean = is TimelineItemAudioContent, is TimelineItemVideoContent, is TimelineItemLocationContent, - is TimelineItemVoiceContent -> true + is TimelineItemVoiceContent, + is TimelineItemGalleryContent, + is TimelineItemAttachmentsContent -> true // Stickers can't be forwarded (yet) so we don't show the option // See https://github.com/element-hq/element-x-android/issues/2161 is TimelineItemStickerContent -> false @@ -78,7 +80,9 @@ fun TimelineItemEventContent.canReact(): Boolean = is TimelineItemLocationContent, is TimelineItemPollContent, is TimelineItemVoiceContent, - is TimelineItemVideoContent -> true + is TimelineItemVideoContent, + is TimelineItemGalleryContent, + is TimelineItemAttachmentsContent -> true is TimelineItemStateContent, is TimelineItemRedactedContent, is TimelineItemLegacyCallInviteContent, @@ -99,6 +103,18 @@ fun TimelineItemEventContent.isEdited(): Boolean = when (this) { */ fun TimelineItemEventContent.isRedacted(): Boolean = this is TimelineItemRedactedContent +/** + * Returns the caption text for content types that support captions. + * Gallery and attachments content types have captions but don't implement + * [TimelineItemEventContentWithAttachment]. + */ +fun TimelineItemEventContent.captionOrNull(): String? = when (this) { + is TimelineItemEventContentWithAttachment -> caption + is TimelineItemGalleryContent -> caption + is TimelineItemAttachmentsContent -> caption + else -> null +} + fun TimelineItemEventContentWithAttachment.duration(): Duration? { return when (this) { is TimelineItemAudioContent -> duration 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..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 @@ -13,8 +13,11 @@ import android.text.style.StyleSpan import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.core.text.buildSpannedString import androidx.core.text.inSpans +import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent +import kotlinx.collections.immutable.toImmutableList import org.jsoup.nodes.Document +import kotlin.time.Duration class TimelineItemEventContentProvider : PreviewParameterProvider { override val values = sequenceOf( @@ -111,3 +114,81 @@ 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.toImmutableList(), +) + +fun aGalleryItem( + filename: String = "photo.jpg", + width: Int = 400, + height: Int = 300, + type: GalleryItem.Type = GalleryItem.Type.Image, + duration: Duration = Duration.ZERO, +) = GalleryItem( + filename = filename, + mimeType = when (type) { + GalleryItem.Type.Video -> "video/mp4" + GalleryItem.Type.Audio -> "audio/mpeg" + GalleryItem.Type.File -> "application/pdf" + GalleryItem.Type.Image -> "image/jpeg" + }, + mediaSource = MediaSource(url = "", json = ""), + type = type, + thumbnailSource = null, + width = width, + height = height, + thumbnailWidth = width, + thumbnailHeight = height, + blurhash = null, + duration = duration, +) + +fun aTimelineItemAttachmentsContent( + body: String = "Attachments", + caption: String? = null, + attachments: List = listOf( + anAttachmentItem(filename = "document.pdf", fileExtension = "pdf"), + anAttachmentItem(filename = "recording.mp3", fileExtension = "mp3", fileSize = 4_500_000L, formattedFileSize = "4.5MB"), + ), +) = TimelineItemAttachmentsContent( + body = body, + caption = caption, + formattedCaption = null, + isEdited = false, + attachments = attachments.toImmutableList(), +) + +fun anAttachmentItem( + filename: String = "file.pdf", + fileExtension: String = "pdf", + fileSize: Long? = 1_000_000L, + formattedFileSize: String = "1MB", + mimeType: String? = null, + thumbnailSource: MediaSource? = null, + hasThumbnail: Boolean = false, +) = AttachmentItem( + filename = filename, + mimeType = mimeType ?: when { + hasThumbnail -> "image/jpeg" + else -> "application/$fileExtension" + }, + mediaSource = MediaSource(url = "", json = ""), + thumbnailSource = thumbnailSource ?: if (hasThumbnail) MediaSource(url = "", json = "") else null, + fileSize = fileSize, + formattedFileSize = formattedFileSize, + fileExtension = fileExtension, +) 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..be19228aed3 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model.event + +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.ui.media.MediaRequestData +import kotlinx.collections.immutable.ImmutableList +import kotlin.time.Duration + +data class TimelineItemGalleryContent( + val body: String, + val caption: String?, + val formattedCaption: CharSequence?, + override val isEdited: Boolean, + val items: ImmutableList, +) : TimelineItemEventContent, TimelineItemEventMutableContent { + override val type: String = "TimelineItemGalleryContent" + + val showCaption = caption != null +} + +data class GalleryItem( + val filename: String, + val mimeType: String, + val mediaSource: MediaSource, + val type: Type, + val thumbnailSource: MediaSource?, + val width: Int?, + val height: Int?, + val thumbnailWidth: Int?, + val thumbnailHeight: Int?, + val blurhash: String?, + val duration: Duration = Duration.ZERO, +) { + enum class Type { + Image, + Video, + Audio, + File; + + fun isMedia() = this in setOf(Image, Video) + } + + val thumbnailMediaRequestData: MediaRequestData by lazy { + MediaRequestData( + source = thumbnailSource ?: mediaSource, + kind = MediaRequestData.Kind.Thumbnail( + width = thumbnailWidth?.toLong() ?: io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH, + height = thumbnailHeight?.toLong() ?: io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT, + ), + ) + } + + val aspectRatio: Float? by lazy { + if (width != null && height != null && height > 0) { + width.toFloat() / height.toFloat() + } else { + null + } + } +} 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 fcaa28a1089..1106c3809e3 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 @@ -40,6 +42,8 @@ fun TimelineItem.mustBeProtected(): Boolean { } else { 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..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 @@ -11,10 +11,12 @@ package io.element.android.features.messages.impl.utils.messagesummary import android.content.Context import dev.zacsweers.metro.ContributesBinding import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent @@ -56,6 +58,16 @@ class DefaultMessageSummaryFormatter( is TimelineItemVideoContent -> context.getString(CommonStrings.common_video) is TimelineItemFileContent -> context.getString(CommonStrings.common_file) is TimelineItemAudioContent -> context.getString(CommonStrings.common_audio) + is TimelineItemGalleryContent -> context.getString(CommonStrings.common_gallery) + is TimelineItemAttachmentsContent -> { + val count = content.attachments.size + val extensions = content.attachments.take(3).joinToString { it.fileExtension } + if (count <= 3) { + extensions + } else { + "$extensions +${count - 3}" + } + } is TimelineItemLegacyCallInviteContent -> context.getString(CommonStrings.common_unsupported_call) is TimelineItemRtcNotificationContent -> when (content.state) { is RtcNotificationState.Declined -> { 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/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt index 3efdec78aea..485c76ead35 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 @@ -33,6 +32,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.FileInfo +import io.element.android.libraries.matrix.api.media.GalleryItemInfo import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder @@ -70,6 +70,7 @@ import io.element.android.tests.testutils.testCoroutineDispatchers import io.mockk.every import io.mockk.mockk import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope @@ -121,8 +122,8 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false)) initialState.eventSink(AttachmentsPreviewEvent.SendAttachment) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = true)) - assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) - assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo))) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(listOf(mediaUploadInfo))) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done) sendFileResult.assertions().isCalledOnce() onDoneListener.assertions().isCalledOnce() @@ -157,8 +158,8 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { advanceUntilIdle() assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false)) initialState.eventSink(AttachmentsPreviewEvent.SendAttachment) - assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) - assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo))) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(listOf(mediaUploadInfo))) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done) sendFileResult.assertions().isCalledOnce() onDoneListener.assertions().isCalledOnce() @@ -193,8 +194,8 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { // Pre-processing finishes processLatch.complete(Unit) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = true)) - assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) - assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo))) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(listOf(mediaUploadInfo))) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done) sendFileResult.assertions().isCalledOnce() onDoneListener.assertions().isCalledOnce() @@ -411,18 +412,14 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) initialState.eventSink(AttachmentsPreviewEvent.SendAttachment) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false)) - assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) - assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo)) - - // Check that the onDoneListener is called so the screen would be dismissed - onDoneListenerResult.assertions().isCalledOnce() - + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo))) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(listOf(mediaUploadInfo))) val failureState = awaitItem() - assertThat(failureState.sendActionState).isEqualTo(SendActionState.Failure(failure, mediaUploadInfo)) + assertThat(failureState.sendActionState).isEqualTo(SendActionState.Failure(failure, listOf(mediaUploadInfo))) sendFileResult.assertions().isCalledOnce() failureState.eventSink(AttachmentsPreviewEvent.CancelAndClearSendState) val clearedState = awaitLastSequentialItem() - assertThat(clearedState.sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) + assertThat(clearedState.sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo))) } } @@ -444,15 +441,12 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) initialState.eventSink(AttachmentsPreviewEvent.SendAttachment) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false)) - assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) - assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo))) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(listOf(mediaUploadInfo))) initialState.eventSink(AttachmentsPreviewEvent.CancelAndClearSendState) - assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo))) // The sending is cancelled and the state is kept at ReadyToUpload ensureAllEventsConsumed() - - // Check that the onDoneListener is called so the screen would be dismissed - onDoneListenerResult.assertions().isCalledOnce() } } @@ -464,7 +458,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { val maxUploadSize = 999L // Set a max upload size smaller than the file size val presenter = createAttachmentsPreviewPresenter( - localMedia = localMedia, + attachments = listOf(Attachment.Media(localMedia)), room = FakeJoinedRoom( liveTimeline = FakeTimeline().apply { sendFileLambda = { _, _, _, _, _ -> @@ -503,7 +497,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { val localMedia = aLocalMedia(uri = Uri.EMPTY, mediaInfo = aVideoMediaInfo()) val presenter = createAttachmentsPreviewPresenter( - localMedia = localMedia, + attachments = listOf(Attachment.Media(localMedia)), room = FakeJoinedRoom( liveTimeline = FakeTimeline().apply { sendFileLambda = { _, _, _, _, _ -> @@ -584,7 +578,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { assertThat(awaitItem().isApplyingImageEdits).isTrue() val appliedState = awaitItem() - assertThat((appliedState.attachment as Attachment.Media).localMedia.uri).isEqualTo(editedUri) + assertThat((appliedState.attachments.first() as Attachment.Media).localMedia.uri).isEqualTo(editedUri) assertThat(appliedState.imageEditorState).isNull() assertThat(appliedState.isApplyingImageEdits).isFalse() } @@ -601,7 +595,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { bottom = 0.9f, ) val presenter = createAttachmentsPreviewPresenter( - localMedia = originalLocalMedia, + attachments = listOf(Attachment.Media(originalLocalMedia)), displayMediaQualitySelectorViews = true, attachmentImageEditor = FakeAttachmentImageEditor { Result.success( @@ -629,7 +623,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { flippedState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits) val appliedState = consumeItemsUntilPredicate { !it.isApplyingImageEdits && it.imageEditorState == null }.last() - assertThat((appliedState.attachment as Attachment.Media).localMedia.uri).isEqualTo(editedUri) + assertThat((appliedState.attachments.first() as Attachment.Media).localMedia.uri).isEqualTo(editedUri) appliedState.eventSink(AttachmentsPreviewEvent.OpenImageEditor) val reopenedState = consumeItemsUntilPredicate { it.imageEditorState != null }.last() @@ -673,21 +667,26 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { } } + @Test fun `present - sendAsFile attachment is pre-processed without image compression`() = runTest { // Even though the user has enabled "Optimize media quality" globally, picking the file // through the Files picker (sendAsFile = true) must skip compression. Regression test // for https://github.com/element-hq/element-x-android/issues/6365 val mediaPreProcessor = FakeMediaPreProcessor() val presenter = createAttachmentsPreviewPresenter( - localMedia = aLocalMedia(mockMediaUrl, anImageMediaInfo()), - sendAsFile = true, + attachments = listOf( + Attachment.Media( + localMedia = aLocalMedia(mockMediaUrl, anImageMediaInfo()), + sendAsFile = true, + ) + ), mediaPreProcessor = mediaPreProcessor, // Selector views are hidden in the sendAsFile flow, which triggers the auto pre-process path. displayMediaQualitySelectorViews = false, mediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider( config = MediaOptimizationConfig( - compressImages = true, - videoCompressionPreset = VideoCompressionPreset.STANDARD, + compressImages = false, + videoCompressionPreset = VideoCompressionPreset.HIGH, ) ), ) @@ -772,8 +771,9 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { fileExtension = "png", ), ) - val presenter = createAttachmentsPreviewPresenter(localMedia = localMedia) - + val presenter = createAttachmentsPreviewPresenter( + attachments = listOf(Attachment.Media(localMedia)), + ) presenter.test { val initialState = awaitItem() assertThat(initialState.canEditImage).isTrue() @@ -795,7 +795,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { ), ) val presenter = createAttachmentsPreviewPresenter( - localMedia = localMedia, + attachments = listOf(Attachment.Media(localMedia)), attachmentImageEditor = FakeAttachmentImageEditor( canEditResult = true, ) { @@ -822,8 +822,12 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { fun `present - sendAsFile video is pre-processed with best fitting preset`() = runTest { val mediaPreProcessor = FakeMediaPreProcessor() val presenter = createAttachmentsPreviewPresenter( - localMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()), - sendAsFile = true, + attachments = listOf( + Attachment.Media( + localMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()), + sendAsFile = true, + ) + ), mediaPreProcessor = mediaPreProcessor, // Selector views are hidden in the sendAsFile flow, which triggers the auto pre-process path. displayMediaQualitySelectorViews = false, @@ -863,11 +867,62 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { } } + @Test + fun `present - sending gallery after image edits restarts preprocessing`() = runTest { + val sendGalleryResult = + lambdaRecorder, 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 onDoneListener = lambdaRecorder { } + val presenter = createAttachmentsPreviewPresenter( + room = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendGalleryLambda = sendGalleryResult + }, + ), + attachments = persistentListOf( + aMediaAttachment(firstLocalMedia), + aMediaAttachment(secondLocalMedia), + ), + displayMediaQualitySelectorViews = false, + attachmentImageEditor = FakeAttachmentImageEditor { + Result.success( + EditedLocalMedia( + localMedia = firstLocalMedia.copy(uri = editedUri), + file = File("/tmp/edited-1.jpeg"), + ) + ) + }, + onDoneListener = OnDoneListener { onDoneListener() }, + ) + + presenter.test { + val initialState = awaitItem() + initialState.eventSink(AttachmentsPreviewEvent.OpenImageEditor) + val editorState = consumeItemsUntilPredicate { it.imageEditorState != null }.last() + + editorState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits) + val appliedState = consumeItemsUntilPredicate { !it.isApplyingImageEdits && it.imageEditorState == null }.last() + + appliedState.eventSink(AttachmentsPreviewEvent.SendAttachment) + consumeItemsUntilPredicate { it.sendActionState == SendActionState.Done } + + sendGalleryResult.assertions().isCalledOnce() + onDoneListener.assertions().isCalledOnce() + } + } + private fun TestScope.createAttachmentsPreviewPresenter( - localMedia: LocalMedia = aLocalMedia( - uri = mockMediaUrl, + attachments: List = listOf( + aMediaAttachment( + aLocalMedia( + uri = mockMediaUrl, + ) + ), ), - sendAsFile: Boolean = false, room: JoinedRoom = FakeJoinedRoom(), timelineMode: Timeline.Mode = Timeline.Mode.Live, permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(), @@ -890,17 +945,21 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { ), mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), attachmentImageEditor: AttachmentImageEditor = FakeAttachmentImageEditor { - Result.success( - EditedLocalMedia( - localMedia = localMedia.copy(uri = Uri.parse("file:///tmp/default-edited.jpeg")), - file = File("/tmp/default-edited.jpeg"), + val localMediaResult = (attachments.first() as? Attachment.Media)?.localMedia?.copy(uri = Uri.parse("file:///tmp/default-edited.jpeg")) + if (localMediaResult != null) { + Result.success( + EditedLocalMedia( + localMedia = localMediaResult, + file = File("/tmp/default-edited.jpeg"), + ) ) - ) + } else { + Result.failure(IllegalStateException("Check test values")) + } }, - videoCompressionPresetSelector: VideoCompressionPresetSelector = VideoCompressionPresetSelector(), ): AttachmentsPreviewPresenter { return AttachmentsPreviewPresenter( - attachment = aMediaAttachment(localMedia, sendAsFile = sendAsFile), + attachments = attachments.toImmutableList(), onDoneListener = onDoneListener, mediaSenderFactory = MediaSenderFactory { timelineMode -> DefaultMediaSender( @@ -918,7 +977,6 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { sessionCoroutineScope = this, dispatchers = testCoroutineDispatchers(), mediaOptimizationSelectorPresenterFactory = mediaOptimizationSelectorPresenterFactory, - videoCompressionPresetSelector = videoCompressionPresetSelector, timelineMode = timelineMode, inReplyToEventId = null, mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, 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..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(mediaUploadInfo = aMediaUploadInfo()) - assertThat(state.mediaUploadInfo()).isEqualTo(mediaUploadInfo) + fun `mediaUploadInfoList() should return the value from Uploading class`() { + val data = listOf(aMediaUploadInfo()) + val state: SendActionState = SendActionState.Sending.Uploading(mediaInfos = data) + assertThat(state.mediaUploadInfoList()).isEqualTo(data) } @Test - fun `mediaUploadInfo() should return the value from ReadyToUpload class`() { - val mediaUploadInfo: MediaUploadInfo = aMediaUploadInfo() - val state: SendActionState = SendActionState.Sending.ReadyToUpload(mediaInfo = aMediaUploadInfo()) - assertThat(state.mediaUploadInfo()).isEqualTo(mediaUploadInfo) + fun `mediaUploadInfoList() should return the value from ReadyToUpload class`() { + val data = listOf(aMediaUploadInfo()) + val state: SendActionState = SendActionState.Sending.ReadyToUpload(mediaInfos = data) + assertThat(state.mediaUploadInfoList()).isEqualTo(data) } @Test - fun `mediaUploadInfo() should return the value from Failure class`() { - val mediaUploadInfo: MediaUploadInfo = aMediaUploadInfo() - val state: SendActionState = SendActionState.Failure(error = IllegalStateException("An error"), mediaUploadInfo = aMediaUploadInfo()) - assertThat(state.mediaUploadInfo()).isEqualTo(mediaUploadInfo) + fun `mediaUploadInfoList() should return the value from Failure class`() { + val data = listOf(aMediaUploadInfo()) + val state: SendActionState = SendActionState.Failure(error = IllegalStateException("An error"), mediaInfos = data) + assertThat(state.mediaUploadInfoList()).isEqualTo(data) } } 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 0b3da9647ec..61b1fc8d261 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.ThreadId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder @@ -274,6 +275,7 @@ class MessageComposerPresenterSlashCommandTest { mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), threadRoot: ThreadId? = null, slashCommandService: SlashCommandService = FakeSlashCommandService(), + featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), ) = MessageComposerPresenter( navigator = navigator, sessionCoroutineScope = this, @@ -312,6 +314,7 @@ class MessageComposerPresenterSlashCommandTest { mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, notificationConversationService = notificationConversationService, slashCommandService = slashCommandService, + featureFlagService = featureFlagService, ).apply { isTesting = true showTextFormatting = isRichTextEditorEnabled 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 0f2a2ce32b5..7c6f47058e8 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 @@ -1534,6 +1535,7 @@ class MessageComposerPresenterTest : RobolectricTest() { mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), threadRoot: ThreadId? = null, slashCommandService: SlashCommandService = FakeSlashCommandService(), + featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), ) = MessageComposerPresenter( navigator = navigator, sessionCoroutineScope = this, @@ -1572,6 +1574,7 @@ class MessageComposerPresenterTest : RobolectricTest() { mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, notificationConversationService = notificationConversationService, slashCommandService = slashCommandService, + featureFlagService = featureFlagService, ).apply { isTesting = true showTextFormatting = isRichTextEditorEnabled 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, 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/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/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..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 @@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageT import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent @@ -104,6 +105,9 @@ class DefaultPinnedMessagesBannerFormatter( is OtherMessageType -> { messageType.body } + is GalleryMessageType -> { + messageType.body.prefixWith(CommonStrings.common_gallery) + } is NoticeMessageType -> { messageType.body } 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..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 @@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent @@ -167,6 +168,9 @@ class DefaultRoomLatestEventFormatter( is OtherMessageType -> { messageType.body } + is GalleryMessageType -> { + messageType.body.prefixWith(sp.getString(CommonStrings.common_gallery)) + } is NoticeMessageType -> { messageType.body } 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") 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 0127e41c995..dc8d4c36cc2 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, + ), } 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..484e701dbcf --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/GalleryItemInfo.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.media + +import java.io.File + +sealed interface GalleryItemInfo { + val file: File + + data class Image( + override val file: File, + val imageInfo: ImageInfo, + val thumbnailFile: File?, + ) : GalleryItemInfo + + data class Video( + override val file: File, + val videoInfo: VideoInfo, + val thumbnailFile: File?, + ) : GalleryItemInfo + + data class Audio( + override val file: File, + val audioInfo: AudioInfo, + ) : GalleryItemInfo + + data class MediaFile( + override val file: File, + val fileInfo: FileInfo, + ) : GalleryItemInfo +} 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..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 @@ -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..b431e82b264 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/FormattedBody.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.media + +import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody +import org.matrix.rustcomponents.sdk.FormattedBody as RustFormattedBody +import org.matrix.rustcomponents.sdk.MessageFormat as RustMessageFormat + +fun FormattedBody.map(): RustFormattedBody = RustFormattedBody( + format = format.map(), + body = body, +) + +private fun io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat.map(): RustMessageFormat { + return when (this) { + io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat.HTML -> RustMessageFormat.Html + io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat.UNKNOWN -> RustMessageFormat.Unknown("") + } +} 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..6e90ea3d621 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryItemInfoMapper.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.media + +import io.element.android.libraries.matrix.api.media.GalleryItemInfo +import org.matrix.rustcomponents.sdk.GalleryItemInfo as RustGalleryItemInfo +import org.matrix.rustcomponents.sdk.UploadSource as RustUploadSource + +fun GalleryItemInfo.map(): RustGalleryItemInfo = when (this) { + is GalleryItemInfo.Image -> { + RustGalleryItemInfo.Image( + imageInfo = imageInfo.map(), + source = RustUploadSource.File(file.path), + caption = null, + formattedCaption = null, + thumbnailSource = thumbnailFile?.path?.let(RustUploadSource::File), + ) + } + is GalleryItemInfo.Video -> { + RustGalleryItemInfo.Video( + videoInfo = videoInfo.map(), + source = RustUploadSource.File(file.path), + caption = null, + formattedCaption = null, + thumbnailSource = thumbnailFile?.path?.let(RustUploadSource::File), + ) + } + is GalleryItemInfo.Audio -> { + RustGalleryItemInfo.Audio( + audioInfo = audioInfo.map(), + source = RustUploadSource.File(file.path), + caption = null, + formattedCaption = null, + ) + } + is GalleryItemInfo.MediaFile -> { + RustGalleryItemInfo.File( + fileInfo = fileInfo.map(), + source = RustUploadSource.File(file.path), + caption = null, + formattedCaption = null, + ) + } +} 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..5bb01cdb564 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryMediaUploadHandlerImpl.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.media + +import io.element.android.libraries.androidutils.file.safeDelete +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.media.MediaUploadHandler +import org.matrix.rustcomponents.sdk.SendGalleryJoinHandle +import java.io.File + +class GalleryMediaUploadHandlerImpl( + private val filesToUpload: List, + 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 58cbb747205..68fcf6287cf 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 @@ -219,6 +219,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 36a8521fb7c..88caf8fcf31 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 @@ -67,6 +69,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.EditedContent import org.matrix.rustcomponents.sdk.FormattedBody +import org.matrix.rustcomponents.sdk.GalleryUploadParameters import org.matrix.rustcomponents.sdk.MessageFormat import org.matrix.rustcomponents.sdk.PollData import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle @@ -535,6 +538,42 @@ class RustTimeline( } } + override suspend fun sendGallery( + items: List, + 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..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 @@ -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..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 @@ -10,6 +10,7 @@ package io.element.android.libraries.mediaupload.api import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.FileInfo +import io.element.android.libraries.matrix.api.media.GalleryItemInfo import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.VideoInfo import java.io.File @@ -31,3 +32,30 @@ fun MediaUploadInfo.allFiles(): List { (this@allFiles as? MediaUploadInfo.Video)?.thumbnailFile, ) } + +fun MediaUploadInfo.toGalleryItemInfo(): GalleryItemInfo { + return when (this) { + is MediaUploadInfo.Image -> GalleryItemInfo.Image( + file = file, + imageInfo = imageInfo, + thumbnailFile = thumbnailFile, + ) + is MediaUploadInfo.Video -> GalleryItemInfo.Video( + file = file, + videoInfo = videoInfo, + thumbnailFile = thumbnailFile, + ) + is MediaUploadInfo.Audio -> GalleryItemInfo.Audio( + file = file, + audioInfo = audioInfo, + ) + is MediaUploadInfo.VoiceMessage -> GalleryItemInfo.Audio( + file = file, + audioInfo = audioInfo, + ) + is MediaUploadInfo.AnyFile -> GalleryItemInfo.MediaFile( + file = file, + fileInfo = fileInfo, + ) + } +} 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..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 @@ -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() } + 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..3a2f2321db5 --- /dev/null +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/GalleryItemData.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.api + +import android.os.Parcelable +import io.element.android.libraries.matrix.api.media.MediaSource +import kotlinx.parcelize.Parcelize + +@Parcelize +data class GalleryItemData( + val filename: String, + val mimeType: String, + val mediaSource: MediaSource, + val thumbnailSource: MediaSource?, + val type: Type, +) : Parcelable { + enum class Type { + Image, + Video, + Audio, + File, + } +} 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..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 @@ -13,6 +13,24 @@ import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.core.UserId import kotlinx.parcelize.Parcelize +@Parcelize +data class GalleryInfo( + val caption: String?, + val formattedCaption: CharSequence? = null, + val senderId: UserId?, + val senderName: String?, + val senderAvatar: String?, + val dateSent: String?, + val dateSentFull: String?, + val initialIndex: Int, + val isAttachment: Boolean, +) : Parcelable + +@Parcelize +data class AvatarInfo( + val filename: String, +) : Parcelable + @Parcelize data class MediaInfo( val filename: String, 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..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,19 +35,29 @@ interface MediaViewerEntryPoint : FeatureEntryPoint { fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) } - data class Params( - val mode: MediaViewerMode, - val eventId: EventId?, - val mediaInfo: MediaInfo, - val mediaSource: MediaSource, - val thumbnailSource: MediaSource?, - val canShowInfo: Boolean, - ) : NodeInputs + sealed interface Params : NodeInputs { + data class RoomMedia( + val mode: MediaViewerMode, + val eventId: EventId?, + val mediaInfo: MediaInfo, + val mediaSource: MediaSource, + val thumbnailSource: MediaSource?, + ) : Params - 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/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..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 @@ -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,42 @@ 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)) { + 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, + ): 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/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 new file mode 100644 index 00000000000..d7ce4862633 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSource.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.viewer + +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.mediaviewer.api.GalleryInfo +import io.element.android.libraries.mediaviewer.api.GalleryItemData +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource +import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.flowOf + +class GalleryMediaGalleryDataSource( + private val data: GroupedMediaItems, +) : MediaGalleryDataSource { + override val isReady: Boolean = true + override fun start(coroutineScope: CoroutineScope) = Unit + override fun groupedMediaItemsFlow() = flowOf(AsyncData.Success(data)) + override fun getLastData(): AsyncData = 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, + galleryInfo: GalleryInfo, + ): GalleryMediaGalleryDataSource { + val imageAndVideoItems = mutableListOf() + val fileItems = mutableListOf() + + galleryItems.forEachIndexed { index, galleryItem -> + val itemMediaInfo = MediaInfo( + filename = galleryItem.filename, + fileSize = null, + caption = galleryInfo.caption, + mimeType = galleryItem.mimeType, + formattedFileSize = "", + fileExtension = galleryItem.filename.substringAfterLast('.', ""), + senderId = galleryInfo.senderId, + senderName = galleryInfo.senderName, + senderAvatar = galleryInfo.senderAvatar, + dateSent = galleryInfo.dateSent, + dateSentFull = galleryInfo.dateSentFull, + waveform = null, + duration = null, + ) + val id = UniqueId("${eventId?.value ?: "gallery"}_$index") + val mediaItem: MediaItem.Event = when (galleryItem.type) { + GalleryItemData.Type.Video -> MediaItem.Video( + id = id, + eventId = eventId, + mediaInfo = itemMediaInfo, + mediaSource = galleryItem.mediaSource, + thumbnailSource = galleryItem.thumbnailSource, + ) + GalleryItemData.Type.Audio -> MediaItem.Audio( + id = id, + eventId = eventId, + mediaInfo = itemMediaInfo, + mediaSource = galleryItem.mediaSource, + ) + GalleryItemData.Type.File -> MediaItem.File( + id = id, + eventId = eventId, + mediaInfo = itemMediaInfo, + mediaSource = galleryItem.mediaSource, + ) + GalleryItemData.Type.Image -> MediaItem.Image( + id = id, + eventId = eventId, + mediaInfo = itemMediaInfo, + mediaSource = galleryItem.mediaSource, + thumbnailSource = galleryItem.thumbnailSource, + ) + } + when (mediaItem) { + is MediaItem.Image, is MediaItem.Video -> imageAndVideoItems.add(mediaItem) + is MediaItem.Audio, is MediaItem.File, is MediaItem.Voice -> fileItems.add(mediaItem) + } + } + + return GalleryMediaGalleryDataSource( + data = GroupedMediaItems( + imageAndVideoItems = imageAndVideoItems.toImmutableList(), + fileItems = fileItems.toImmutableList(), + ) + ) + } + } +} 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..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 } @@ -98,9 +97,12 @@ class MediaViewerDataSource( /** * Find the index of the page corresponding to the given eventId, or null if not found. */ - fun findEventIndex(eventId: EventId?): Int? { + fun findEventIndex(eventId: EventId?, mediaSource: MediaSource? = null): Int? { if (eventId == null) return null - return dataFlow.value.indexOfFirst { (it as? MediaViewerPageData.MediaViewerData)?.eventId == eventId }.takeIf { it >= 0 } + return dataFlow.value.indexOfFirst { + val pageData = it as? MediaViewerPageData.MediaViewerData + pageData?.eventId == eventId && (mediaSource == null || pageData.mediaSource == mediaSource) + }.takeIf { it >= 0 } } @VisibleForTesting 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..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 @@ -74,42 +74,49 @@ class MediaViewerNode( callback.onDone() } - private val mediaGallerySource = if (inputs.mode == MediaViewerEntryPoint.MediaViewerMode.SingleMedia) { - SingleMediaGalleryDataSource.createFrom(inputs) - } else { - val eventId = inputs.eventId - if (eventId == null) { - // Should not happen - timelineMediaGalleryDataSource - } else { - // Can we use a specific timeline? - val timelineMode = inputs.mode.getTimelineMode() - when (timelineMode) { - null -> timelineMediaGalleryDataSource - Timeline.Mode.Live, - is Timeline.Mode.FocusedOnEvent, - is Timeline.Mode.Thread -> { - // Does timelineMediaGalleryDataSource knows the eventId? - val lastData = timelineMediaGalleryDataSource.getLastData().dataOrNull() - val isEventKnown = lastData?.hasEvent(eventId) == true - if (isEventKnown) { - timelineMediaGalleryDataSource - } else { + private val mediaGallerySource = when (inputs) { + is MediaViewerEntryPoint.Params.Avatar -> + SingleMediaGalleryDataSource.createFrom(inputs) + is MediaViewerEntryPoint.Params.EventGallery -> + GalleryMediaGalleryDataSource.createFrom( + eventId = inputs.eventId, + galleryItems = inputs.galleryItems, + galleryInfo = inputs.galleryInfo, + ) + is MediaViewerEntryPoint.Params.RoomMedia -> { + val eventId = inputs.eventId + if (eventId == null) { + // Should not happen + timelineMediaGalleryDataSource + } else { + // Can we use a specific timeline? + val timelineMode = inputs.mode.getTimelineMode() + when (timelineMode) { + Timeline.Mode.Live, + is Timeline.Mode.FocusedOnEvent, + is Timeline.Mode.Thread -> { + // Does timelineMediaGalleryDataSource knows the eventId? + val lastData = timelineMediaGalleryDataSource.getLastData().dataOrNull() + val isEventKnown = lastData?.hasEvent(eventId) == true + if (isEventKnown) { + timelineMediaGalleryDataSource + } else { + focusedTimelineMediaGalleryDataSourceFactory.createFor( + eventId = eventId, + mediaItem = inputs.toMediaItem(), + onlyPinnedEvents = false, + ) + } + } + Timeline.Mode.PinnedEvents -> { focusedTimelineMediaGalleryDataSourceFactory.createFor( eventId = eventId, mediaItem = inputs.toMediaItem(), - onlyPinnedEvents = false, + onlyPinnedEvents = true, ) } + Timeline.Mode.Media -> timelineMediaGalleryDataSource } - Timeline.Mode.PinnedEvents -> { - focusedTimelineMediaGalleryDataSourceFactory.createFor( - eventId = eventId, - mediaItem = inputs.toMediaItem(), - onlyPinnedEvents = true, - ) - } - Timeline.Mode.Media -> timelineMediaGalleryDataSource } } } @@ -118,7 +125,18 @@ class MediaViewerNode( inputs = inputs, navigator = this, dataSource = MediaViewerDataSource( - mode = inputs.mode, + mode = when (inputs) { + is MediaViewerEntryPoint.Params.Avatar -> + MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media) + is MediaViewerEntryPoint.Params.EventGallery -> + if (inputs.galleryInfo.isAttachment) { + MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(Timeline.Mode.Media) + } else { + MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media) + } + is MediaViewerEntryPoint.Params.RoomMedia -> + inputs.mode + }, coroutineScope = lifecycleScope, dispatcher = coroutineDispatchers.computation, galleryDataSource = mediaGallerySource, @@ -149,10 +167,9 @@ class MediaViewerNode( } } -internal fun MediaViewerEntryPoint.MediaViewerMode.getTimelineMode(): Timeline.Mode? { +internal fun MediaViewerEntryPoint.MediaViewerMode.getTimelineMode(): Timeline.Mode { return when (this) { is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> timelineMode is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> timelineMode - else -> null } } 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 eec99468f77..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) ?: 0) } + val currentIndex = remember { + val firstIndex = if (inputs is MediaViewerEntryPoint.Params.EventGallery) { + // Order is reversed so we have to reverse the index + inputs.galleryItems.lastIndex - inputs.galleryInfo.initialIndex + } else { + dataSource.findEventIndex(eventId, mediaSource) ?: 0 + } + mutableIntStateOf(firstIndex) + } val data = dataSource.produceState { flow -> flow.collectLatest { new -> val existingItem = value.getOrNull(currentIndex.intValue) val newItem = new.getOrNull(currentIndex.intValue) - if (existingItem is MediaViewerPageData.MediaViewerData && existingItem.eventId == inputs.eventId && newItem != existingItem) { - currentIndex.intValue = dataSource.findEventIndex(inputs.eventId) ?: 0 + if (existingItem is MediaViewerPageData.MediaViewerData && existingItem.eventId == eventId && newItem != existingItem) { + currentIndex.intValue = dataSource.findEventIndex(eventId, mediaSource) ?: 0 } else if (currentIndex.intValue > 0 && value.firstOrNull() is MediaViewerPageData.Loading && new.firstOrNull() !is MediaViewerPageData.Loading) { // Restore index based on the eventId after the initial items have been loaded - currentIndex.intValue = dataSource.findEventIndex(inputs.eventId) ?: 0 + currentIndex.intValue = dataSource.findEventIndex(eventId, mediaSource) ?: 0 } value = new } @@ -140,7 +151,8 @@ class MediaViewerPresenter( mediaBottomSheetState = MediaBottomSheetState.Hidden navigator.onForwardClick( eventId = event.eventId, - fromPinnedEvents = inputs.mode.getTimelineMode() == Timeline.Mode.PinnedEvents, + // TODO We can have a pinned gallery + fromPinnedEvents = inputs.mode()?.getTimelineMode() == Timeline.Mode.PinnedEvents, ) } is MediaViewerEvent.OpenInfo -> coroutineScope.launch { @@ -176,11 +188,11 @@ class MediaViewerPresenter( } return MediaViewerState( - initiallySelectedEventId = inputs.eventId, + initiallySelectedEventId = eventId, listData = data.value, currentIndex = currentIndex.intValue, snackbarMessage = snackbarMessage, - canShowInfo = inputs.canShowInfo, + canShowInfo = inputs !is MediaViewerEntryPoint.Params.Avatar, mediaBottomSheetState = mediaBottomSheetState, eventSink = ::handleEvent, ) @@ -224,13 +236,14 @@ class MediaViewerPresenter( } private fun showNoMoreItemsSnackbar() { - val messageResId = when (inputs.mode) { - MediaViewerEntryPoint.MediaViewerMode.SingleMedia, - is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> R.string.screen_media_details_no_more_media_to_show - is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> R.string.screen_media_details_no_more_files_to_show + if (inputs is MediaViewerEntryPoint.Params.RoomMedia) { + val messageResId = when (inputs.mode) { + is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> R.string.screen_media_details_no_more_media_to_show + is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> R.string.screen_media_details_no_more_files_to_show + } + val message = SnackbarMessage(messageResId) + snackbarDispatcher.post(message) } - val message = SnackbarMessage(messageResId) - snackbarDispatcher.post(message) } private fun CoroutineScope.downloadMedia( @@ -292,3 +305,21 @@ class MediaViewerPresenter( } } } + +private fun MediaViewerEntryPoint.Params.eventId() = when (this) { + is MediaViewerEntryPoint.Params.Avatar -> null + is MediaViewerEntryPoint.Params.EventGallery -> eventId + is MediaViewerEntryPoint.Params.RoomMedia -> eventId +} + +private fun MediaViewerEntryPoint.Params.mediaSource() = when (this) { + is MediaViewerEntryPoint.Params.Avatar -> mediaSource + is MediaViewerEntryPoint.Params.EventGallery -> null + is MediaViewerEntryPoint.Params.RoomMedia -> mediaSource +} + +private fun MediaViewerEntryPoint.Params.mode() = when (this) { + is MediaViewerEntryPoint.Params.Avatar -> null + is MediaViewerEntryPoint.Params.EventGallery -> null + is MediaViewerEntryPoint.Params.RoomMedia -> mode +} 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/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/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/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..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( @@ -639,7 +644,7 @@ class MediaViewerPresenterTest { } ) ) - skipItems(2) + skipItems(1) val stateWithSnackbar = awaitItem() assertThat(stateWithSnackbar.snackbarMessage!!.messageResId).isEqualTo(expectedSnackbarResId) } @@ -932,29 +937,31 @@ class MediaViewerPresenterTest { internal fun TestScope.createMediaViewerPresenter( localMediaFactory: LocalMediaFactory, + inputs: MediaViewerEntryPoint.Params? = null, eventId: EventId? = null, - mode: MediaViewerEntryPoint.MediaViewerMode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia, + mode: MediaViewerEntryPoint.MediaViewerMode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media), matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(), localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(), mediaGalleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource( startLambda = { }, ), - canShowInfo: Boolean = true, mediaViewerNavigator: MediaViewerNavigator = FakeMediaViewerNavigator(), room: JoinedRoom = FakeJoinedRoom( liveTimeline = FakeTimeline(), ), ): MediaViewerPresenter { + val actualInputs = inputs ?: createMediaViewerEntryPointParams(eventId = eventId, mode = mode) + val actualMode = when (actualInputs) { + is MediaViewerEntryPoint.Params.Avatar -> MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media) + is MediaViewerEntryPoint.Params.EventGallery -> MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media) + is MediaViewerEntryPoint.Params.RoomMedia -> actualInputs.mode + } return MediaViewerPresenter( - inputs = createMediaViewerEntryPointParams( - eventId = eventId, - mode = mode, - canShowInfo = canShowInfo, - ), + inputs = actualInputs, navigator = mediaViewerNavigator, dataSource = MediaViewerDataSource( coroutineScope = backgroundScope, - mode = mode, + mode = actualMode, dispatcher = testCoroutineDispatchers().computation, galleryDataSource = mediaGalleryDataSource, mediaLoader = matrixMediaLoader, @@ -969,13 +976,11 @@ internal fun TestScope.createMediaViewerPresenter( internal fun createMediaViewerEntryPointParams( eventId: EventId? = null, - mode: MediaViewerEntryPoint.MediaViewerMode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia, - canShowInfo: Boolean = true, -) = MediaViewerEntryPoint.Params( + mode: MediaViewerEntryPoint.MediaViewerMode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode = Timeline.Mode.Media), +) = MediaViewerEntryPoint.Params.RoomMedia( mode = mode, eventId = eventId, mediaInfo = TESTED_MEDIA_INFO, mediaSource = aMediaSource(), thumbnailSource = null, - canShowInfo = canShowInfo, ) 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, ) } 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 } } 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/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'") } 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", 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_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_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Day_1_en.png new file mode 100644 index 00000000000..bacf9f828c8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Day_1_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_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_TimelineItemAttachmentsListView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Night_1_en.png new file mode 100644 index 00000000000..3d0945d8d63 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Night_1_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_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_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_1_en.png new file mode 100644 index 00000000000..27e0ff2c995 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_1_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_TimelineItemGalleryView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_2_en.png new file mode 100644 index 00000000000..1b523f9e941 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_2_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_TimelineItemGalleryView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_3_en.png new file mode 100644 index 00000000000..1b523f9e941 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_3_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_TimelineItemGalleryView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_4_en.png new file mode 100644 index 00000000000..1b523f9e941 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_4_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_TimelineItemGalleryView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_5_en.png new file mode 100644 index 00000000000..e169c383a03 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_5_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_TimelineItemGalleryView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_6_en.png new file mode 100644 index 00000000000..4d6c2f99147 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_6_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_TimelineItemGalleryView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_7_en.png new file mode 100644 index 00000000000..5bf7ad4ca9c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_7_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_TimelineItemGalleryView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_8_en.png new file mode 100644 index 00000000000..0a7fb286f15 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_8_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_TimelineItemGalleryView_Day_9_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_9_en.png new file mode 100644 index 00000000000..14393b4454e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Day_9_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_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_TimelineItemGalleryView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_1_en.png new file mode 100644 index 00000000000..f94ac769f42 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_1_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_TimelineItemGalleryView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_2_en.png new file mode 100644 index 00000000000..01dad748943 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_2_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_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_3_en.png new file mode 100644 index 00000000000..01dad748943 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_3_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_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_4_en.png new file mode 100644 index 00000000000..01dad748943 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_4_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_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_5_en.png new file mode 100644 index 00000000000..71d008600cc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_5_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_TimelineItemGalleryView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_6_en.png new file mode 100644 index 00000000000..d2eafbba352 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_6_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_TimelineItemGalleryView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_7_en.png new file mode 100644 index 00000000000..c4f9b5ac917 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_7_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_TimelineItemGalleryView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_8_en.png new file mode 100644 index 00000000000..c272bf13da9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_8_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_TimelineItemGalleryView_Night_9_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_9_en.png new file mode 100644 index 00000000000..146e3a92119 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryView_Night_9_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_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_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_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_1_en.png new file mode 100644 index 00000000000..b42850242be --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_1_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_TimelineItemEventRowWithGallery_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_2_en.png new file mode 100644 index 00000000000..ea80da06509 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_2_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_TimelineItemEventRowWithGallery_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_3_en.png new file mode 100644 index 00000000000..fa21d48f59f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_3_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_TimelineItemEventRowWithGallery_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_4_en.png new file mode 100644 index 00000000000..3adce582764 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_4_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_TimelineItemEventRowWithGallery_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_5_en.png new file mode 100644 index 00000000000..68ad2c556a4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_5_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_TimelineItemEventRowWithGallery_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_6_en.png new file mode 100644 index 00000000000..64378d8a5d9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_6_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_TimelineItemEventRowWithGallery_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_7_en.png new file mode 100644 index 00000000000..d1c6a220dcb --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_7_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_TimelineItemEventRowWithGallery_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_8_en.png new file mode 100644 index 00000000000..ee7a9300850 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_8_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_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_TimelineItemEventRowWithGallery_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_1_en.png new file mode 100644 index 00000000000..65441d23c34 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_1_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_TimelineItemEventRowWithGallery_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_2_en.png new file mode 100644 index 00000000000..4a107c909b7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_2_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_TimelineItemEventRowWithGallery_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_3_en.png new file mode 100644 index 00000000000..cc52531a723 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_3_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_TimelineItemEventRowWithGallery_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_4_en.png new file mode 100644 index 00000000000..349a26729ea --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_4_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_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_5_en.png new file mode 100644 index 00000000000..15975510ea9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_5_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_TimelineItemEventRowWithGallery_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_6_en.png new file mode 100644 index 00000000000..328b0cb0889 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_6_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_TimelineItemEventRowWithGallery_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_7_en.png new file mode 100644 index 00000000000..3ce2075468a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_7_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_TimelineItemEventRowWithGallery_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_8_en.png new file mode 100644 index 00000000000..2b120273a88 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_8_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_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 e2822122703bbaa75502ec1dcde7e78b104a80d1 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Fri, 26 Jun 2026 14:18:39 +0000 Subject: [PATCH 02/27] Update screenshots --- ...ponents.event_TimelineItemAttachmentsListView_Day_0_en.png | 4 ++-- ...ponents.event_TimelineItemAttachmentsListView_Day_1_en.png | 4 ++-- ...nents.event_TimelineItemAttachmentsListView_Night_0_en.png | 4 ++-- ...nents.event_TimelineItemAttachmentsListView_Night_1_en.png | 4 ++-- ...ne.components_TimelineItemEventRowWithGallery_Day_5_en.png | 4 ++-- ...ne.components_TimelineItemEventRowWithGallery_Day_6_en.png | 4 ++-- ...ne.components_TimelineItemEventRowWithGallery_Day_7_en.png | 4 ++-- ...ne.components_TimelineItemEventRowWithGallery_Day_8_en.png | 4 ++-- ....components_TimelineItemEventRowWithGallery_Night_5_en.png | 4 ++-- ....components_TimelineItemEventRowWithGallery_Night_6_en.png | 4 ++-- ....components_TimelineItemEventRowWithGallery_Night_7_en.png | 4 ++-- ....components_TimelineItemEventRowWithGallery_Night_8_en.png | 4 ++-- 12 files changed, 24 insertions(+), 24 deletions(-) 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 index 49f11c81e48..8afe26e76bb 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d470f7df702048d8467c1daa4304b77adb641e5f481a9414ea5e9b72279eb1cf -size 29813 +oid sha256:c4390e005f2ee64c62795578b87bce877d6445c30173bc1c1e45eea6db117d9b +size 29822 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Day_1_en.png index bacf9f828c8..3fae9947ca2 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:40a46a63f4e27303a9ac0daec115296a880233b8e5fc717c4b75616d552810c3 -size 16170 +oid sha256:21f1d31507e7ffa764c9f58ce7e0cb5624adb05e49d735f7d2a549d6b03c9da6 +size 16174 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 index 64e931c9d22..b08cbda1fee 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:072d87f706f949dcb0c2600b51745709747af71c552aa6a264760a0c8a254b51 -size 29010 +oid sha256:04149afc743a7f1f651db40aa4561847c81c51478aee9b76c7c9b0fab88cac5a +size 29014 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Night_1_en.png index 3d0945d8d63..54d451ecea0 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d4a8a086ac268a9d2dd45f738cffced918ed834e3892f42cf220d2b9745b15fe -size 15481 +oid sha256:7078a50ab56312e7472054c3bf0fc816753c887091c1887ad4ccd7851ca8db24 +size 15478 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_5_en.png index 68ad2c556a4..23ce668685c 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5291e32a1ff8bfb7f07c028c43733258d5db74537e956d4ace08346ae102a3c5 -size 48372 +oid sha256:620849b893491d33f5b0a2a0f2d0814c40d111026aee52e299b893909b08afb8 +size 48309 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_6_en.png index 64378d8a5d9..7521205f858 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:af2f2c775304355a8540fb675008d3a3d0d562962035077a90c87be837ad5bd5 -size 103522 +oid sha256:50ee16d8894cb50860bbe0dafb7f1e6002ba18336070875699409c1ffd1b7de0 +size 103322 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_7_en.png index d1c6a220dcb..829dabebd57 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:40613ee16dae9b237a300d0d589045e8bbac8e61f842f232b78375aa27c18ebf -size 76264 +oid sha256:7381b146b64c1281d8e3032539c38270b086d2173d635d969dce3e1f24e07081 +size 76539 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_8_en.png index ee7a9300850..dee2a3b0254 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e7f38d776f1d538e1cf5c8d56d8a0af99a18ca5d5b4bdae2eb7f912cd49ab227 -size 39146 +oid sha256:d737d3c634a812cd2338be8c372de94a5f0a959e56be9b5acd6a4a2d83b57879 +size 39262 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_5_en.png index 15975510ea9..8857763fe8e 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:70e3df3c1f51facbcb45764e346aed8081071bac01ecc6ed0b98f954d586c847 -size 47988 +oid sha256:a89c2d2ece84f2bd9a4613c84ee7a4090cd00e79de88bbdb0dc3b82f6f867b1e +size 47837 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_6_en.png index 328b0cb0889..ac841eead4d 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a55b40b059220aeb73e62a645cf84fa4fd31c21aad0fa188c9e08b70215b1043 -size 102588 +oid sha256:9e2cccbfba9adf2c1aa799e98410f32fc3895a28552577ae5a49860cd04e9d9e +size 102247 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_7_en.png index 3ce2075468a..b6c5b1a0069 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d19ef86a311234a3ce43778171f4efaef867513aa990f7bea19d3f8cff4db8a4 -size 75688 +oid sha256:b98b6d69d157f84bae3dcd9f8cfba2f98d1f8dbd923b9d8ec803dffa18f7d92a +size 75860 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_8_en.png index 2b120273a88..14597ddb08f 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGallery_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:28859edab7881602214e4c8d4206d64ae7aea00cd8b92a031a1e383f0be71653 -size 38672 +oid sha256:9da51e0ce86a07b09548161e60b0bf76cae5dc7fa53929ea23f81f416347de6d +size 38627 From a7e6d8af6e3df3cffca918c5a593a072a34f7542 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 26 Jun 2026 16:21:16 +0200 Subject: [PATCH 03/27] Better fix to render all items in the gallery. Should be able to render mixed items. --- .../features/messages/impl/MessagesFlowNode.kt | 2 -- .../android/libraries/mediaviewer/api/MediaInfo.kt | 1 - .../mediaviewer/api/MediaViewerEntryPoint.kt | 3 +++ .../impl/viewer/GalleryMediaGalleryDataSource.kt | 6 ++---- .../mediaviewer/impl/viewer/MediaViewerDataSource.kt | 1 + .../mediaviewer/impl/viewer/MediaViewerNode.kt | 11 +++++------ .../mediaviewer/impl/viewer/MediaViewerPresenter.kt | 2 ++ 7 files changed, 13 insertions(+), 13 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 9b8577f8ac4..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 @@ -812,7 +812,6 @@ class MessagesFlowNode( mode = DateFormatterMode.Full, ), initialIndex = galleryItemIndex, - isAttachment = false, ) val galleryItems = event.content.items.map { galleryItem -> GalleryItemData( @@ -845,7 +844,6 @@ 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 962c73b126e..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 @@ -23,7 +23,6 @@ data class GalleryInfo( val dateSent: String?, val dateSentFull: String?, val initialIndex: Int, - val isAttachment: Boolean, ) : Parcelable @Parcelize 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 ba9f371df18..7930175d537 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 @@ -58,6 +58,9 @@ interface MediaViewerEntryPoint : FeatureEntryPoint { } sealed interface MediaViewerMode : Parcelable { + @Parcelize + data object EventGallery : MediaViewerMode + @Parcelize data class TimelineImagesAndVideos(val timelineMode: Timeline.Mode) : MediaViewerMode 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 d7ce4862633..d20987cd805 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 @@ -85,10 +85,8 @@ class GalleryMediaGalleryDataSource( 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) - } + // Add all items to imageAndVideoItems (we can have mixed attachments) + imageAndVideoItems.add(mediaItem) } return GalleryMediaGalleryDataSource( 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 8e9d5a9fdfc..7806ac2d95d 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 @@ -64,6 +64,7 @@ class MediaViewerDataSource( private val galleryMode = when (mode) { is MediaViewerMode.TimelineImagesAndVideos -> MediaGalleryMode.Images is MediaViewerMode.TimelineFilesAndAudios -> MediaGalleryMode.Files + MediaViewerMode.EventGallery -> MediaGalleryMode.Images } // Map of sourceUrl to local media state 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 d7e4985cbb4..f169c6a3f1e 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 @@ -116,6 +116,8 @@ class MediaViewerNode( ) } Timeline.Mode.Media -> timelineMediaGalleryDataSource + // null should not happen, input should be MediaViewerEntryPoint.Params.EventGallery in this case + null -> timelineMediaGalleryDataSource } } } @@ -129,11 +131,7 @@ class MediaViewerNode( is MediaViewerEntryPoint.Params.Avatar -> MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media) is MediaViewerEntryPoint.Params.EventGallery -> - if (inputs.galleryInfo.isAttachment) { - MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(Timeline.Mode.Media) - } else { - MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media) - } + MediaViewerEntryPoint.MediaViewerMode.EventGallery is MediaViewerEntryPoint.Params.RoomMedia -> inputs.mode }, @@ -167,9 +165,10 @@ 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 + MediaViewerEntryPoint.MediaViewerMode.EventGallery -> 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 b379f91b316..1890142b8db 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 @@ -240,6 +240,8 @@ class MediaViewerPresenter( val messageResId = when (inputs.mode) { is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> R.string.screen_media_details_no_more_media_to_show is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> R.string.screen_media_details_no_more_files_to_show + // Should not happen + MediaViewerEntryPoint.MediaViewerMode.EventGallery -> R.string.screen_media_details_no_more_media_to_show } val message = SnackbarMessage(messageResId) snackbarDispatcher.post(message) From b62242cdd2090d5dc6136e26d702df0d12d9bfe0 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 26 Jun 2026 17:36:27 +0200 Subject: [PATCH 04/27] Fix sharing gallery from pinned event timeline. --- .../features/messages/impl/MessagesFlowNode.kt | 10 ++++++++++ .../features/messages/impl/MessagesNode.kt | 15 ++++++++++++--- .../features/messages/impl/MessagesView.kt | 12 ++++++++---- .../MessagesViewWithIdentityChangePreview.kt | 2 +- .../impl/threads/ThreadedMessagesNode.kt | 17 ++++++++++++++--- .../mediaviewer/api/MediaViewerEntryPoint.kt | 3 ++- .../impl/viewer/MediaViewerDataSource.kt | 2 +- .../mediaviewer/impl/viewer/MediaViewerNode.kt | 4 ++-- .../impl/viewer/MediaViewerPresenter.kt | 15 ++++++--------- 9 files changed, 56 insertions(+), 24 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 da7f3d8d5be..cbab26fe549 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 @@ -159,6 +159,7 @@ class MessagesFlowNode( @Parcelize data class GalleryViewer( + val fromPinnedMessages: Boolean, val eventId: EventId?, val galleryInfo: GalleryInfo, val canUseOverlay: Boolean, @@ -265,11 +266,13 @@ class MessagesFlowNode( } override fun handleGalleryItemClick( + timelineMode: Timeline.Mode, event: TimelineItem.Event, galleryItemIndex: Int, canUseOverlay: Boolean, ): Boolean { return processGalleryEventClick( + timelineMode = timelineMode, event = event, canUseOverlay = canUseOverlay, galleryItemIndex = galleryItemIndex, @@ -405,6 +408,7 @@ class MessagesFlowNode( eventId = navTarget.eventId, galleryInfo = navTarget.galleryInfo, galleryItems = navTarget.galleryItems, + fromPinnedMessages = navTarget.fromPinnedMessages, ) val callback = object : MediaViewerEntryPoint.Callback { override fun onDone() { @@ -520,6 +524,7 @@ class MessagesFlowNode( canUseOverlay: Boolean, ) { processGalleryEventClick( + timelineMode = Timeline.Mode.PinnedEvents, event = event, galleryItemIndex = galleryItemIndex, canUseOverlay = canUseOverlay, @@ -574,11 +579,13 @@ class MessagesFlowNode( } override fun handleGalleryItemClick( + timelineMode: Timeline.Mode, event: TimelineItem.Event, galleryItemIndex: Int, canUseOverlay: Boolean, ): Boolean { return processGalleryEventClick( + timelineMode = timelineMode, event = event, canUseOverlay = canUseOverlay, galleryItemIndex = galleryItemIndex, @@ -792,6 +799,7 @@ class MessagesFlowNode( } private fun processGalleryEventClick( + timelineMode: Timeline.Mode, event: TimelineItem.Event, galleryItemIndex: Int, canUseOverlay: Boolean, @@ -827,6 +835,7 @@ class MessagesFlowNode( galleryInfo = galleryInfo, canUseOverlay = canUseOverlay, galleryItems = galleryItems, + fromPinnedMessages = timelineMode is Timeline.Mode.PinnedEvents ) } is TimelineItemAttachmentsContent -> { @@ -859,6 +868,7 @@ class MessagesFlowNode( galleryInfo = galleryInfo, canUseOverlay = canUseOverlay, galleryItems = galleryItems, + fromPinnedMessages = timelineMode is Timeline.Mode.PinnedEvents ) } else -> null 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 1eb3a82e1ab..a7e88460473 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, galleryItemIndex: Int, canUseOverlay: Boolean): Boolean + fun handleGalleryItemClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, galleryItemIndex: Int, canUseOverlay: Boolean): Boolean fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) fun navigateToRoomMemberDetails(userId: UserId) fun handlePermalinkClick(data: PermalinkData) @@ -290,8 +290,17 @@ class MessagesNode( } } }, - onGalleryEventItemClick = { event, index -> - callback.handleGalleryItemClick(event, index, canUseOverlay) + onGalleryEventItemClick = { isLive, event, index -> + if (isLive) { + callback.handleGalleryItemClick(timelineController.mainTimelineMode(), event, index, canUseOverlay) + } else { + val detachedTimelineMode = timelineController.detachedTimelineMode() + if (detachedTimelineMode != null) { + callback.handleGalleryItemClick(detachedTimelineMode, event, index, canUseOverlay) + } else { + false + } + } }, onUserDataClick = callback::navigateToRoomMemberDetails, onLinkClick = { url, customTab -> 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 1e9471fa4b8..0be003d8d38 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,7 @@ fun MessagesView( onBackClick: () -> Unit, onRoomDetailsClick: () -> Unit, onEventContentClick: (isLive: Boolean, event: TimelineItem.Event) -> Boolean, - onGalleryEventItemClick: (event: TimelineItem.Event, index: Int) -> Boolean, + onGalleryEventItemClick: (isLive: Boolean, event: TimelineItem.Event, index: Int) -> Boolean, onUserDataClick: (UserId) -> Unit, onLinkClick: (String, Boolean) -> Unit, onSendLocationClick: () -> Unit, @@ -260,7 +260,11 @@ fun MessagesView( state = state, onContentClick = ::onContentClick, onGalleryItemClick = { event, index -> - val hideKeyboard = onGalleryEventItemClick(event, index) + val hideKeyboard = onGalleryEventItemClick( + state.timelineState.isLive, + event, + index, + ) if (hideKeyboard) { localView.hideKeyboard() } @@ -646,6 +650,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class) onBackClick = {}, onRoomDetailsClick = {}, onEventContentClick = { _, _ -> false }, + onGalleryEventItemClick = { _, _, _ -> false }, onUserDataClick = {}, onLinkClick = { _, _ -> }, onSendLocationClick = {}, @@ -655,7 +660,6 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class) forceJumpToBottomVisibility = true, knockRequestsBannerView = {}, onThreadsListClick = {}, - onGalleryEventItemClick = { _, _ -> false }, ) } @@ -702,6 +706,7 @@ internal fun MessagesViewA11yPreview() = ElementPreview { onBackClick = {}, onRoomDetailsClick = {}, onEventContentClick = { _, _ -> false }, + onGalleryEventItemClick = { _, _, _ -> false }, onUserDataClick = {}, onLinkClick = { _, _ -> }, onSendLocationClick = {}, @@ -709,7 +714,6 @@ 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 2bad9a64d9d..d7dd95eb95a 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 @@ -35,6 +35,7 @@ internal fun MessagesViewWithIdentityChangePreview( onBackClick = {}, onRoomDetailsClick = {}, onEventContentClick = { _, _ -> false }, + onGalleryEventItemClick = { _, _, _ -> false }, onUserDataClick = {}, onLinkClick = { _, _ -> }, onSendLocationClick = {}, @@ -43,6 +44,5 @@ 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 4d527736918..c3e06b880b5 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, galleryItemIndex: Int, canUseOverlay: Boolean): Boolean + fun handleGalleryItemClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, galleryItemIndex: Int, canUseOverlay: Boolean): Boolean fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) fun navigateToRoomMemberDetails(userId: UserId) fun handlePermalinkClick(data: PermalinkData) @@ -290,8 +290,19 @@ class ThreadedMessagesNode( } } == true }, - onGalleryEventItemClick = { event, index -> - callback.handleGalleryItemClick(event, index, canUseOverlay) + onGalleryEventItemClick = { isLive, event, index -> + timelineController?.let { controller -> + if (isLive) { + callback.handleGalleryItemClick(controller.mainTimelineMode(), event, index, canUseOverlay) + } else { + val detachedTimelineMode = controller.detachedTimelineMode() + if (detachedTimelineMode != null) { + callback.handleGalleryItemClick(detachedTimelineMode, event, index, canUseOverlay) + } else { + false + } + } + } == true }, onUserDataClick = callback::navigateToRoomMemberDetails, onLinkClick = { url, customTab -> 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 7930175d537..f6de6cf8399 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 @@ -48,6 +48,7 @@ interface MediaViewerEntryPoint : FeatureEntryPoint { val eventId: EventId?, val galleryInfo: GalleryInfo, val galleryItems: List, + val fromPinnedMessages: Boolean, ) : Params data class Avatar( @@ -59,7 +60,7 @@ interface MediaViewerEntryPoint : FeatureEntryPoint { sealed interface MediaViewerMode : Parcelable { @Parcelize - data object EventGallery : MediaViewerMode + data class EventGallery(val fromPinnedMessages: Boolean) : MediaViewerMode @Parcelize data class TimelineImagesAndVideos(val timelineMode: Timeline.Mode) : MediaViewerMode 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 7806ac2d95d..7cbeafa0b2b 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 @@ -64,7 +64,7 @@ class MediaViewerDataSource( private val galleryMode = when (mode) { is MediaViewerMode.TimelineImagesAndVideos -> MediaGalleryMode.Images is MediaViewerMode.TimelineFilesAndAudios -> MediaGalleryMode.Files - MediaViewerMode.EventGallery -> MediaGalleryMode.Images + is MediaViewerMode.EventGallery -> MediaGalleryMode.Images } // Map of sourceUrl to local media state 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 f169c6a3f1e..2298981543a 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 @@ -131,7 +131,7 @@ class MediaViewerNode( is MediaViewerEntryPoint.Params.Avatar -> MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media) is MediaViewerEntryPoint.Params.EventGallery -> - MediaViewerEntryPoint.MediaViewerMode.EventGallery + MediaViewerEntryPoint.MediaViewerMode.EventGallery(fromPinnedMessages = inputs.fromPinnedMessages) is MediaViewerEntryPoint.Params.RoomMedia -> inputs.mode }, @@ -169,6 +169,6 @@ internal fun MediaViewerEntryPoint.MediaViewerMode.getTimelineMode(): Timeline.M return when (this) { is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> timelineMode is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> timelineMode - MediaViewerEntryPoint.MediaViewerMode.EventGallery -> null + is MediaViewerEntryPoint.MediaViewerMode.EventGallery -> 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 1890142b8db..da181a80d3d 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 @@ -151,8 +151,11 @@ class MediaViewerPresenter( mediaBottomSheetState = MediaBottomSheetState.Hidden navigator.onForwardClick( eventId = event.eventId, - // TODO We can have a pinned gallery - fromPinnedEvents = inputs.mode()?.getTimelineMode() == Timeline.Mode.PinnedEvents, + fromPinnedEvents = when (inputs) { + is MediaViewerEntryPoint.Params.RoomMedia -> inputs.mode == Timeline.Mode.PinnedEvents + is MediaViewerEntryPoint.Params.EventGallery -> inputs.fromPinnedMessages + is MediaViewerEntryPoint.Params.Avatar -> false + }, ) } is MediaViewerEvent.OpenInfo -> coroutineScope.launch { @@ -241,7 +244,7 @@ class MediaViewerPresenter( is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> R.string.screen_media_details_no_more_media_to_show is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> R.string.screen_media_details_no_more_files_to_show // Should not happen - MediaViewerEntryPoint.MediaViewerMode.EventGallery -> R.string.screen_media_details_no_more_media_to_show + is MediaViewerEntryPoint.MediaViewerMode.EventGallery -> R.string.screen_media_details_no_more_media_to_show } val message = SnackbarMessage(messageResId) snackbarDispatcher.post(message) @@ -319,9 +322,3 @@ private fun MediaViewerEntryPoint.Params.mediaSource() = when (this) { 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 -} From 95e656cc32b8bf1ee775dd8d6946f5e1e2531316 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 29 Jun 2026 09:11:51 +0200 Subject: [PATCH 05/27] Fix regression on tests. --- .../android/features/messages/impl/MessagesViewTest.kt | 3 ++- .../mediaviewer/impl/viewer/MediaViewerPresenter.kt | 6 +++++- .../io/element/android/tests/testutils/EnsureNeverCalled.kt | 6 ++++++ 3 files changed, 13 insertions(+), 2 deletions(-) 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 4e0556df1f6..5d2dceaf31c 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 @@ -68,6 +68,7 @@ import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EnsureCalledOnceWithTwoParamsAndResult import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EnsureNeverCalledWithThreeParamsAndResult import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParamsAndResult import io.element.android.tests.testutils.EventsRecorder @@ -685,7 +686,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(), + onGalleryEventItemClick: (isLive: Boolean, event: TimelineItem.Event, index: Int) -> Boolean = EnsureNeverCalledWithThreeParamsAndResult(), onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(), onLinkClick: (String, Boolean) -> Unit = EnsureNeverCalledWithTwoParams(), onSendLocationClick: () -> Unit = EnsureNeverCalled(), 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 da181a80d3d..eaea61b5a58 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 @@ -152,7 +152,11 @@ class MediaViewerPresenter( navigator.onForwardClick( eventId = event.eventId, fromPinnedEvents = when (inputs) { - is MediaViewerEntryPoint.Params.RoomMedia -> inputs.mode == Timeline.Mode.PinnedEvents + is MediaViewerEntryPoint.Params.RoomMedia -> when (val myMode = inputs.mode) { + is MediaViewerEntryPoint.MediaViewerMode.EventGallery -> myMode.fromPinnedMessages + is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> myMode.getTimelineMode() == Timeline.Mode.PinnedEvents + is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> myMode.getTimelineMode() == Timeline.Mode.PinnedEvents + } is MediaViewerEntryPoint.Params.EventGallery -> inputs.fromPinnedMessages is MediaViewerEntryPoint.Params.Avatar -> false }, diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/EnsureNeverCalled.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/EnsureNeverCalled.kt index 7c3d4ec600e..f910285b52e 100644 --- a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/EnsureNeverCalled.kt +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/EnsureNeverCalled.kt @@ -45,3 +45,9 @@ class EnsureNeverCalledWithThreeParams : (T, U, V) -> Unit { lambdaError("Should not be called and is called with $p1, $p2 and $p3") } } + +class EnsureNeverCalledWithThreeParamsAndResult : (T, U, V) -> R { + override fun invoke(p1: T, p2: U, p3: V): R { + lambdaError("Should not be called and is called with $p1, $p2 and $p3") + } +} From b2a7c6548a182275e13c50e0e4a2a75de28d36fb Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 29 Jun 2026 10:01:21 +0200 Subject: [PATCH 06/27] Add missing test on TimelineItemContentMessageFactory --- .../TimelineItemContentMessageFactoryTest.kt | 428 ++++++++++++++++++ 1 file changed, 428 insertions(+) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt index 5453aac2054..fc01184764c 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt @@ -19,9 +19,13 @@ import androidx.core.text.inSpans import androidx.core.text.toSpannable import com.google.common.truth.Truth.assertThat import io.element.android.features.location.api.Location +import io.element.android.features.messages.impl.timeline.model.event.AttachmentItem +import io.element.android.features.messages.impl.timeline.model.event.GalleryItem +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent @@ -47,6 +51,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageT import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody +import io.element.android.libraries.matrix.api.timeline.item.event.GalleryItemType +import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType @@ -767,6 +773,428 @@ class TimelineItemContentMessageFactoryTest : RobolectricTest() { (result as TimelineItemTextContent).formattedBody.assertSpannedEquals(expectedSpanned) } + @Test + fun `test create GalleryMessageType with image items returns TimelineItemGalleryContent`() = runTest { + val sut = createTimelineItemContentMessageFactory() + val result = sut.create( + content = createMessageContent( + type = GalleryMessageType( + body = "Gallery body", + formatted = null, + items = listOf( + GalleryItemType.Image( + content = ImageMessageType( + filename = "image.jpg", + caption = null, + formattedCaption = null, + source = MediaSource("image_url"), + info = ImageInfo( + height = 100L, + width = 200L, + mimetype = MimeTypes.Jpeg, + size = 888L, + thumbnailInfo = ThumbnailInfo(height = 10L, width = 20L, mimetype = MimeTypes.Jpeg, size = 111L), + thumbnailSource = MediaSource("thumbnail_url"), + blurhash = A_BLUR_HASH, + ) + ) + ) + ) + ) + ), + senderId = A_USER_ID, + senderProfile = aProfileDetails(), + eventId = AN_EVENT_ID, + ) + val expected = TimelineItemGalleryContent( + body = "Gallery body", + caption = "Gallery body", + formattedCaption = null, + isEdited = false, + items = persistentListOf( + GalleryItem( + filename = "image.jpg", + mimeType = MimeTypes.Jpeg, + mediaSource = MediaSource("image_url"), + type = GalleryItem.Type.Image, + thumbnailSource = MediaSource("thumbnail_url"), + width = 200, + height = 100, + thumbnailWidth = 20, + thumbnailHeight = 10, + blurhash = A_BLUR_HASH, + ) + ) + ) + assertThat(result).isEqualTo(expected) + } + + @Test + fun `test create GalleryMessageType with video items returns TimelineItemGalleryContent`() = runTest { + val sut = createTimelineItemContentMessageFactory() + val result = sut.create( + content = createMessageContent( + type = GalleryMessageType( + body = "Gallery body", + formatted = null, + items = listOf( + GalleryItemType.Video( + content = VideoMessageType( + filename = "video.mp4", + caption = null, + formattedCaption = null, + source = MediaSource("video_url"), + info = VideoInfo( + duration = 1.minutes, + height = 100L, + width = 200L, + mimetype = MimeTypes.Mp4, + size = 1234L, + thumbnailInfo = ThumbnailInfo(height = 10L, width = 20L, mimetype = MimeTypes.Jpeg, size = 111L), + thumbnailSource = MediaSource("thumbnail_url"), + blurhash = A_BLUR_HASH, + ) + ) + ) + ) + ) + ), + senderId = A_USER_ID, + senderProfile = aProfileDetails(), + eventId = AN_EVENT_ID, + ) + val expected = TimelineItemGalleryContent( + body = "Gallery body", + caption = "Gallery body", + formattedCaption = null, + isEdited = false, + items = persistentListOf( + GalleryItem( + filename = "video.mp4", + mimeType = MimeTypes.Mp4, + mediaSource = MediaSource("video_url"), + type = GalleryItem.Type.Video, + thumbnailSource = MediaSource("thumbnail_url"), + width = 200, + height = 100, + thumbnailWidth = 20, + thumbnailHeight = 10, + blurhash = A_BLUR_HASH, + duration = 1.minutes, + ) + ) + ) + assertThat(result).isEqualTo(expected) + } + + @Test + fun `test create GalleryMessageType with audio items returns TimelineItemAttachmentsContent`() = runTest { + val sut = createTimelineItemContentMessageFactory() + val result = sut.create( + content = createMessageContent( + type = GalleryMessageType( + body = "Gallery body", + formatted = null, + items = listOf( + GalleryItemType.Audio( + content = AudioMessageType( + filename = "audio.mp3", + caption = null, + formattedCaption = null, + source = MediaSource("audio_url"), + info = AudioInfo( + duration = 1.minutes, + size = 123L, + mimetype = MimeTypes.Mp3, + ) + ) + ) + ) + ) + ), + senderId = A_USER_ID, + senderProfile = aProfileDetails(), + eventId = AN_EVENT_ID, + ) + val expected = TimelineItemAttachmentsContent( + body = "Gallery body", + caption = "Gallery body", + formattedCaption = null, + isEdited = false, + attachments = persistentListOf( + AttachmentItem( + filename = "audio.mp3", + mimeType = MimeTypes.Mp3, + mediaSource = MediaSource("audio_url"), + thumbnailSource = null, + fileSize = 123L, + formattedFileSize = "123 Bytes", + fileExtension = "mp3", + ) + ) + ) + assertThat(result).isEqualTo(expected) + } + + @Test + fun `test create GalleryMessageType with file items returns TimelineItemAttachmentsContent`() = runTest { + val sut = createTimelineItemContentMessageFactory() + val result = sut.create( + content = createMessageContent( + type = GalleryMessageType( + body = "Gallery body", + formatted = null, + items = listOf( + GalleryItemType.File( + content = FileMessageType( + filename = "document.pdf", + caption = null, + formattedCaption = null, + source = MediaSource("file_url"), + info = FileInfo( + mimetype = MimeTypes.Pdf, + size = 456L, + thumbnailInfo = null, + thumbnailSource = null, + ) + ) + ) + ) + ) + ), + senderId = A_USER_ID, + senderProfile = aProfileDetails(), + eventId = AN_EVENT_ID, + ) + val expected = TimelineItemAttachmentsContent( + body = "Gallery body", + caption = "Gallery body", + formattedCaption = null, + isEdited = false, + attachments = persistentListOf( + AttachmentItem( + filename = "document.pdf", + mimeType = MimeTypes.Pdf, + mediaSource = MediaSource("file_url"), + thumbnailSource = null, + fileSize = 456L, + formattedFileSize = "456 Bytes", + fileExtension = "pdf", + ) + ) + ) + assertThat(result).isEqualTo(expected) + } + + @Test + fun `test create GalleryMessageType with image items without thumbnails returns TimelineItemAttachmentsContent`() = runTest { + val sut = createTimelineItemContentMessageFactory() + val result = sut.create( + content = createMessageContent( + type = GalleryMessageType( + body = "Gallery body", + formatted = null, + items = listOf( + GalleryItemType.Image( + content = ImageMessageType( + filename = "image.jpg", + caption = null, + formattedCaption = null, + source = MediaSource("image_url"), + info = ImageInfo( + height = 100L, + width = 200L, + mimetype = MimeTypes.Jpeg, + size = 888L, + thumbnailInfo = null, + thumbnailSource = null, + blurhash = null, + ) + ) + ) + ) + ) + ), + senderId = A_USER_ID, + senderProfile = aProfileDetails(), + eventId = AN_EVENT_ID, + ) + val expected = TimelineItemAttachmentsContent( + body = "Gallery body", + caption = "Gallery body", + formattedCaption = null, + isEdited = false, + attachments = persistentListOf( + AttachmentItem( + filename = "image.jpg", + mimeType = MimeTypes.Jpeg, + mediaSource = MediaSource("image_url"), + thumbnailSource = null, + fileSize = 888L, + formattedFileSize = "888 Bytes", + fileExtension = "jpg", + ) + ) + ) + assertThat(result).isEqualTo(expected) + } + + @Test + fun `test create GalleryMessageType with only Other items returns TimelineItemAttachmentsContent with empty attachments`() = runTest { + val sut = createTimelineItemContentMessageFactory() + val result = sut.create( + content = createMessageContent( + type = GalleryMessageType( + body = "Gallery body", + formatted = null, + items = listOf( + GalleryItemType.Other(itemType = "unknown_type", body = "Some body") + ) + ) + ), + senderId = A_USER_ID, + senderProfile = aProfileDetails(), + eventId = AN_EVENT_ID, + ) + val expected = TimelineItemAttachmentsContent( + body = "Gallery body", + caption = "Gallery body", + formattedCaption = null, + isEdited = false, + attachments = persistentListOf(), + ) + assertThat(result).isEqualTo(expected) + } + + @Test + fun `test create GalleryMessageType with mixed image and file items returns TimelineItemAttachmentsContent`() = runTest { + val sut = createTimelineItemContentMessageFactory() + val result = sut.create( + content = createMessageContent( + type = GalleryMessageType( + body = "Gallery body", + formatted = null, + items = listOf( + GalleryItemType.Image( + content = ImageMessageType( + filename = "image.jpg", + caption = null, + formattedCaption = null, + source = MediaSource("image_url"), + info = ImageInfo( + height = 100L, + width = 200L, + mimetype = MimeTypes.Jpeg, + size = 888L, + thumbnailInfo = ThumbnailInfo(height = 10L, width = 20L, mimetype = MimeTypes.Jpeg, size = 111L), + thumbnailSource = MediaSource("thumbnail_url"), + blurhash = null, + ) + ) + ), + GalleryItemType.File( + content = FileMessageType( + filename = "document.pdf", + caption = null, + formattedCaption = null, + source = MediaSource("file_url"), + info = FileInfo( + mimetype = MimeTypes.Pdf, + size = 456L, + thumbnailInfo = null, + thumbnailSource = null, + ) + ) + ) + ) + ) + ), + senderId = A_USER_ID, + senderProfile = aProfileDetails(), + eventId = AN_EVENT_ID, + ) + assertThat(result).isInstanceOf(TimelineItemAttachmentsContent::class.java) + val attachmentsContent = result as TimelineItemAttachmentsContent + assertThat(attachmentsContent.attachments).hasSize(2) + } + + @Test + fun `test create GalleryMessageType with formatted caption returns TimelineItemGalleryContent with formatted caption`() = runTest { + val sut = createTimelineItemContentMessageFactory() + val result = sut.create( + content = createMessageContent( + type = GalleryMessageType( + body = "Gallery body", + formatted = FormattedBody(MessageFormat.HTML, "formatted"), + items = listOf( + GalleryItemType.Image( + content = ImageMessageType( + filename = "image.jpg", + caption = null, + formattedCaption = null, + source = MediaSource("image_url"), + info = ImageInfo( + height = 100L, + width = 200L, + mimetype = MimeTypes.Jpeg, + size = 888L, + thumbnailInfo = ThumbnailInfo(height = 10L, width = 20L, mimetype = MimeTypes.Jpeg, size = 111L), + thumbnailSource = MediaSource("thumbnail_url"), + blurhash = null, + ) + ) + ) + ) + ) + ), + senderId = A_USER_ID, + senderProfile = aProfileDetails(), + eventId = AN_EVENT_ID, + ) + assertThat(result).isInstanceOf(TimelineItemGalleryContent::class.java) + val galleryContent = result as TimelineItemGalleryContent + galleryContent.formattedCaption.assertSpannedEquals(SpannedString("formatted")) + } + + @Test + fun `test create GalleryMessageType with empty body has no caption`() = runTest { + val sut = createTimelineItemContentMessageFactory() + val result = sut.create( + content = createMessageContent( + type = GalleryMessageType( + body = "", + formatted = null, + items = listOf( + GalleryItemType.Image( + content = ImageMessageType( + filename = "image.jpg", + caption = null, + formattedCaption = null, + source = MediaSource("image_url"), + info = ImageInfo( + height = 100L, + width = 200L, + mimetype = MimeTypes.Jpeg, + size = 888L, + thumbnailInfo = ThumbnailInfo(height = 10L, width = 20L, mimetype = MimeTypes.Jpeg, size = 111L), + thumbnailSource = MediaSource("thumbnail_url"), + blurhash = null, + ) + ) + ) + ) + ) + ), + senderId = A_USER_ID, + senderProfile = aProfileDetails(), + eventId = AN_EVENT_ID, + ) + assertThat(result).isInstanceOf(TimelineItemGalleryContent::class.java) + val galleryContent = result as TimelineItemGalleryContent + assertThat(galleryContent.caption).isNull() + assertThat(galleryContent.formattedCaption).isNull() + } + private fun createMessageContent( body: String = "Body", inReplyTo: InReplyTo? = null, From 1a5336e650a9ffd067b981d4db6cb4aedb88c24a Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 29 Jun 2026 10:15:00 +0200 Subject: [PATCH 07/27] Add missing test on EventItemFactory --- .../datasource/DefaultEventItemFactoryTest.kt | 318 ++++++++++++++++++ 1 file changed, 318 insertions(+) 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 707cb088ddb..468b040f6ac 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 @@ -20,12 +20,15 @@ import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.notification.CallIntent import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.GalleryItemType +import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType @@ -377,6 +380,321 @@ class DefaultEventItemFactoryTest { ) } + @Test + fun `create for GalleryMessageType with image item`() { + val factory = createEventItemFactory() + val result = factory.create( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + content = aMessageContent( + messageType = GalleryMessageType( + body = "Gallery body", + formatted = null, + items = listOf( + GalleryItemType.Image( + content = ImageMessageType( + filename = "image.jpg", + caption = "caption", + formattedCaption = null, + source = MediaSource("image_url"), + info = ImageInfo( + mimetype = MimeTypes.Jpeg, + size = 123L, + thumbnailInfo = null, + thumbnailSource = MediaSource("thumbnail_url"), + height = 1L, + width = 2L, + blurhash = null, + ) + ) + ) + ) + ) + ) + ) + ) + ) + assertThat(result).containsExactly( + MediaItem.Image( + id = UniqueId("aUniqueId_0"), + eventId = AN_EVENT_ID, + mediaInfo = MediaInfo( + mimeType = MimeTypes.Jpeg, + filename = "image.jpg", + fileSize = 123L, + caption = "caption", + formattedFileSize = "123 Bytes", + fileExtension = "jpg", + senderId = A_USER_ID, + senderName = "alice", + senderAvatar = null, + dateSent = "0 Day false", + dateSentFull = "0 Full false", + waveform = null, + duration = null, + ), + mediaSource = MediaSource("image_url"), + thumbnailSource = MediaSource("thumbnail_url"), + ) + ) + } + + @Test + fun `create for GalleryMessageType with video item`() { + val factory = createEventItemFactory() + val result = factory.create( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + content = aMessageContent( + messageType = GalleryMessageType( + body = "Gallery body", + formatted = null, + items = listOf( + GalleryItemType.Video( + content = VideoMessageType( + filename = "video.mp4", + caption = "caption", + formattedCaption = null, + source = MediaSource("video_url"), + info = VideoInfo( + mimetype = MimeTypes.Mp4, + size = 123L, + thumbnailInfo = null, + duration = 123.seconds, + height = 1L, + width = 2L, + thumbnailSource = MediaSource("thumbnail_url"), + blurhash = null, + ) + ) + ) + ) + ) + ) + ) + ) + ) + assertThat(result).containsExactly( + MediaItem.Video( + id = UniqueId("aUniqueId_0"), + eventId = AN_EVENT_ID, + mediaInfo = MediaInfo( + mimeType = MimeTypes.Mp4, + filename = "video.mp4", + fileSize = 123L, + caption = "caption", + formattedFileSize = "123 Bytes", + fileExtension = "mp4", + senderId = A_USER_ID, + senderName = "alice", + senderAvatar = null, + dateSent = "0 Day false", + dateSentFull = "0 Full false", + waveform = null, + duration = "2:03", + ), + mediaSource = MediaSource("video_url"), + thumbnailSource = MediaSource("thumbnail_url"), + ) + ) + } + + @Test + fun `create for GalleryMessageType with audio item`() { + val factory = createEventItemFactory() + val result = factory.create( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + content = aMessageContent( + messageType = GalleryMessageType( + body = "Gallery body", + formatted = null, + items = listOf( + GalleryItemType.Audio( + content = AudioMessageType( + filename = "audio.mp3", + caption = "caption", + formattedCaption = null, + source = MediaSource("audio_url"), + info = AudioInfo( + mimetype = MimeTypes.Mp3, + size = 123L, + duration = 456.seconds, + ) + ) + ) + ) + ) + ) + ) + ) + ) + assertThat(result).containsExactly( + MediaItem.Audio( + id = UniqueId("aUniqueId_0"), + eventId = AN_EVENT_ID, + mediaInfo = MediaInfo( + mimeType = MimeTypes.Mp3, + filename = "audio.mp3", + fileSize = 123L, + caption = "caption", + formattedFileSize = "123 Bytes", + fileExtension = "mp3", + senderId = A_USER_ID, + senderName = "alice", + senderAvatar = null, + dateSent = "0 Day false", + dateSentFull = "0 Full false", + waveform = null, + duration = null, + ), + mediaSource = MediaSource("audio_url"), + ) + ) + } + + @Test + fun `create for GalleryMessageType with file item`() { + val factory = createEventItemFactory() + val result = factory.create( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + content = aMessageContent( + messageType = GalleryMessageType( + body = "Gallery body", + formatted = null, + items = listOf( + GalleryItemType.File( + content = FileMessageType( + filename = "document.pdf", + caption = "caption", + formattedCaption = null, + source = MediaSource("file_url"), + info = FileInfo( + mimetype = MimeTypes.Pdf, + size = 456L, + thumbnailInfo = null, + thumbnailSource = null, + ) + ) + ) + ) + ) + ) + ) + ) + ) + assertThat(result).containsExactly( + MediaItem.File( + id = UniqueId("aUniqueId_0"), + eventId = AN_EVENT_ID, + mediaInfo = MediaInfo( + mimeType = MimeTypes.Pdf, + filename = "document.pdf", + fileSize = 456L, + caption = "caption", + formattedFileSize = "456 Bytes", + fileExtension = "pdf", + senderId = A_USER_ID, + senderName = "alice", + senderAvatar = null, + dateSent = "0 Day false", + dateSentFull = "0 Full false", + waveform = null, + duration = null, + ), + mediaSource = MediaSource("file_url"), + ) + ) + } + + @Test + fun `create for GalleryMessageType with Other item returns empty list`() { + val factory = createEventItemFactory() + val result = factory.create( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + content = aMessageContent( + messageType = GalleryMessageType( + body = "Gallery body", + formatted = null, + items = listOf( + GalleryItemType.Other(itemType = "unknown_type", body = "Some body") + ) + ) + ) + ) + ) + ) + assertThat(result).isEmpty() + } + + @Test + fun `create for GalleryMessageType with multiple items produces indexed IDs`() { + val factory = createEventItemFactory() + val result = factory.create( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + content = aMessageContent( + messageType = GalleryMessageType( + body = "Gallery body", + formatted = null, + items = listOf( + GalleryItemType.Image( + content = ImageMessageType( + filename = "image.jpg", + caption = null, + formattedCaption = null, + source = MediaSource("image_url"), + info = ImageInfo( + mimetype = MimeTypes.Jpeg, + size = 123L, + thumbnailInfo = null, + thumbnailSource = null, + height = 1L, + width = 2L, + blurhash = null, + ) + ) + ), + GalleryItemType.Video( + content = VideoMessageType( + filename = "video.mp4", + caption = null, + formattedCaption = null, + source = MediaSource("video_url"), + info = VideoInfo( + mimetype = MimeTypes.Mp4, + size = 456L, + thumbnailInfo = null, + duration = null, + height = 1L, + width = 2L, + thumbnailSource = null, + blurhash = null, + ) + ) + ), + GalleryItemType.Other(itemType = "unknown_type", body = "ignored"), + ) + ) + ) + ) + ) + ) + assertThat(result).hasSize(2) + assertThat(result[0]).isInstanceOf(MediaItem.Image::class.java) + assertThat((result[0] as MediaItem.Image).id).isEqualTo(UniqueId("aUniqueId_0")) + assertThat(result[1]).isInstanceOf(MediaItem.Video::class.java) + assertThat((result[1] as MediaItem.Video).id).isEqualTo(UniqueId("aUniqueId_1")) + } + @Test fun `create for StickerMessageType`() { val factory = createEventItemFactory() From fb7bc50cdbd694de336f8173783eb5747972c474 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 29 Jun 2026 10:15:28 +0200 Subject: [PATCH 08/27] Rename test class. --- .../{DefaultEventItemFactoryTest.kt => EventItemFactoryTest.kt} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/{DefaultEventItemFactoryTest.kt => EventItemFactoryTest.kt} (99%) 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/EventItemFactoryTest.kt similarity index 99% rename from libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultEventItemFactoryTest.kt rename to libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactoryTest.kt index 468b040f6ac..de166803860 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/EventItemFactoryTest.kt @@ -60,7 +60,7 @@ import kotlinx.collections.immutable.toImmutableList import org.junit.Test import kotlin.time.Duration.Companion.seconds -class DefaultEventItemFactoryTest { +class EventItemFactoryTest { @Test fun `create check all null cases`() { val factory = createEventItemFactory() From dd3beb11bdaaf855bf899a6babc80500f1ee9e43 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 29 Jun 2026 10:28:10 +0200 Subject: [PATCH 09/27] Add unit test on GalleryMediaGalleryDataSource and simplify the code. --- .../viewer/GalleryMediaGalleryDataSource.kt | 13 +- .../GalleryMediaGalleryDataSourceTest.kt | 277 ++++++++++++++++++ 2 files changed, 282 insertions(+), 8 deletions(-) create mode 100644 libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSourceTest.kt 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 d20987cd805..2dfb11a89f7 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 @@ -17,6 +17,7 @@ import io.element.android.libraries.mediaviewer.api.MediaInfo import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.flowOf @@ -37,9 +38,7 @@ class GalleryMediaGalleryDataSource( galleryItems: List, galleryInfo: GalleryInfo, ): GalleryMediaGalleryDataSource { - val imageAndVideoItems = mutableListOf() - val fileItems = mutableListOf() - + val mixedItems = mutableListOf() galleryItems.forEachIndexed { index, galleryItem -> val itemMediaInfo = MediaInfo( filename = galleryItem.filename, @@ -85,14 +84,12 @@ class GalleryMediaGalleryDataSource( thumbnailSource = galleryItem.thumbnailSource, ) } - // Add all items to imageAndVideoItems (we can have mixed attachments) - imageAndVideoItems.add(mediaItem) + mixedItems.add(mediaItem) } - return GalleryMediaGalleryDataSource( data = GroupedMediaItems( - imageAndVideoItems = imageAndVideoItems.toImmutableList(), - fileItems = fileItems.toImmutableList(), + imageAndVideoItems = mixedItems.toImmutableList(), + fileItems = persistentListOf(), ) ) } diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSourceTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSourceTest.kt new file mode 100644 index 00000000000..710aaff1abf --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSourceTest.kt @@ -0,0 +1,277 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.viewer + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.mediaviewer.api.GalleryInfo +import io.element.android.libraries.mediaviewer.api.GalleryItemData +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.impl.gallery.aGroupedMediaItems +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemFile +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class GalleryMediaGalleryDataSourceTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `isReady is true`() { + val sut = GalleryMediaGalleryDataSource(aGroupedMediaItems()) + assertThat(sut.isReady).isTrue() + } + + @Test + fun `function start is no op`() = runTest { + val sut = GalleryMediaGalleryDataSource(aGroupedMediaItems()) + sut.start(backgroundScope) + } + + @Test + fun `function loadMore is no op`() = runTest { + val sut = GalleryMediaGalleryDataSource(aGroupedMediaItems()) + sut.loadMore(Timeline.PaginationDirection.BACKWARDS) + sut.loadMore(Timeline.PaginationDirection.FORWARDS) + } + + @Test + fun `function deleteItem is no op`() = runTest { + val sut = GalleryMediaGalleryDataSource(aGroupedMediaItems()) + sut.deleteItem(AN_EVENT_ID) + } + + @Test + fun `getLastData should return the data`() { + val data = aGroupedMediaItems( + imageAndVideoItems = listOf(aMediaItemImage()), + fileItems = listOf(aMediaItemFile()), + ) + val sut = GalleryMediaGalleryDataSource(data) + assertThat(sut.getLastData()).isEqualTo(AsyncData.Success(data)) + } + + @Test + fun `groupedMediaItemsFlow emits a single item`() = runTest { + val data = aGroupedMediaItems( + imageAndVideoItems = listOf(aMediaItemImage()), + fileItems = listOf(aMediaItemFile()), + ) + val sut = GalleryMediaGalleryDataSource(data) + sut.groupedMediaItemsFlow().test { + assertThat(awaitItem()).isEqualTo(AsyncData.Success(data)) + awaitComplete() + } + } + + @Test + fun `createFrom with image item creates MediaItem Image in imageAndVideoItems`() { + val result = GalleryMediaGalleryDataSource.createFrom( + eventId = AN_EVENT_ID, + galleryItems = listOf( + GalleryItemData( + filename = "image.jpg", + mimeType = MimeTypes.Jpeg, + mediaSource = MediaSource("image_url"), + thumbnailSource = MediaSource("thumbnail_url"), + type = GalleryItemData.Type.Image, + ) + ), + galleryInfo = aGalleryInfo(), + ) + val data = (result.getLastData() as AsyncData.Success).data + assertThat(data.fileItems).isEmpty() + assertThat(data.imageAndVideoItems).containsExactly( + MediaItem.Image( + id = UniqueId("${AN_EVENT_ID.value}_0"), + eventId = AN_EVENT_ID, + mediaInfo = expectedMediaInfo("image.jpg", MimeTypes.Jpeg), + mediaSource = MediaSource("image_url"), + thumbnailSource = MediaSource("thumbnail_url"), + ) + ) + } + + @Test + fun `createFrom with video item creates MediaItem Video in imageAndVideoItems`() { + val result = GalleryMediaGalleryDataSource.createFrom( + eventId = AN_EVENT_ID, + galleryItems = listOf( + GalleryItemData( + filename = "video.mp4", + mimeType = MimeTypes.Mp4, + mediaSource = MediaSource("video_url"), + thumbnailSource = MediaSource("thumbnail_url"), + type = GalleryItemData.Type.Video, + ) + ), + galleryInfo = aGalleryInfo(), + ) + val data = (result.getLastData() as AsyncData.Success).data + assertThat(data.fileItems).isEmpty() + assertThat(data.imageAndVideoItems).containsExactly( + MediaItem.Video( + id = UniqueId("${AN_EVENT_ID.value}_0"), + eventId = AN_EVENT_ID, + mediaInfo = expectedMediaInfo("video.mp4", MimeTypes.Mp4), + mediaSource = MediaSource("video_url"), + thumbnailSource = MediaSource("thumbnail_url"), + ) + ) + } + + @Test + fun `createFrom with audio item creates MediaItem Audio in imageAndVideoItems`() { + val result = GalleryMediaGalleryDataSource.createFrom( + eventId = AN_EVENT_ID, + galleryItems = listOf( + GalleryItemData( + filename = "audio.mp3", + mimeType = MimeTypes.Mp3, + mediaSource = MediaSource("audio_url"), + thumbnailSource = null, + type = GalleryItemData.Type.Audio, + ) + ), + galleryInfo = aGalleryInfo(), + ) + val data = (result.getLastData() as AsyncData.Success).data + assertThat(data.fileItems).isEmpty() + assertThat(data.imageAndVideoItems).containsExactly( + MediaItem.Audio( + id = UniqueId("${AN_EVENT_ID.value}_0"), + eventId = AN_EVENT_ID, + mediaInfo = expectedMediaInfo("audio.mp3", MimeTypes.Mp3), + mediaSource = MediaSource("audio_url"), + ) + ) + } + + @Test + fun `createFrom with file item creates MediaItem File in imageAndVideoItems`() { + val result = GalleryMediaGalleryDataSource.createFrom( + eventId = AN_EVENT_ID, + galleryItems = listOf( + GalleryItemData( + filename = "document.pdf", + mimeType = MimeTypes.Pdf, + mediaSource = MediaSource("file_url"), + thumbnailSource = null, + type = GalleryItemData.Type.File, + ) + ), + galleryInfo = aGalleryInfo(), + ) + val data = (result.getLastData() as AsyncData.Success).data + assertThat(data.fileItems).isEmpty() + assertThat(data.imageAndVideoItems).containsExactly( + MediaItem.File( + id = UniqueId("${AN_EVENT_ID.value}_0"), + eventId = AN_EVENT_ID, + mediaInfo = expectedMediaInfo("document.pdf", MimeTypes.Pdf), + mediaSource = MediaSource("file_url"), + ) + ) + } + + @Test + fun `createFrom uses gallery prefix when eventId is null`() { + val result = GalleryMediaGalleryDataSource.createFrom( + eventId = null, + galleryItems = listOf( + GalleryItemData( + filename = "image.jpg", + mimeType = MimeTypes.Jpeg, + mediaSource = MediaSource("image_url"), + thumbnailSource = null, + type = GalleryItemData.Type.Image, + ) + ), + galleryInfo = aGalleryInfo(), + ) + val data = (result.getLastData() as AsyncData.Success).data + val item = data.imageAndVideoItems.single() as MediaItem.Image + assertThat(item.id).isEqualTo(UniqueId("gallery_0")) + assertThat(item.eventId).isNull() + } + + @Test + fun `createFrom with multiple items produces indexed IDs and all go to imageAndVideoItems`() { + val result = GalleryMediaGalleryDataSource.createFrom( + eventId = AN_EVENT_ID, + galleryItems = listOf( + GalleryItemData( + filename = "image.jpg", + mimeType = MimeTypes.Jpeg, + mediaSource = MediaSource("image_url"), + thumbnailSource = null, + type = GalleryItemData.Type.Image, + ), + GalleryItemData( + filename = "document.pdf", + mimeType = MimeTypes.Pdf, + mediaSource = MediaSource("file_url"), + thumbnailSource = null, + type = GalleryItemData.Type.File, + ), + GalleryItemData( + filename = "video.mp4", + mimeType = MimeTypes.Mp4, + mediaSource = MediaSource("video_url"), + thumbnailSource = null, + type = GalleryItemData.Type.Video, + ), + ), + galleryInfo = aGalleryInfo(), + ) + val data = (result.getLastData() as AsyncData.Success).data + assertThat(data.fileItems).isEmpty() + assertThat(data.imageAndVideoItems).hasSize(3) + assertThat((data.imageAndVideoItems[0] as MediaItem.Image).id).isEqualTo(UniqueId("${AN_EVENT_ID.value}_0")) + assertThat((data.imageAndVideoItems[1] as MediaItem.File).id).isEqualTo(UniqueId("${AN_EVENT_ID.value}_1")) + assertThat((data.imageAndVideoItems[2] as MediaItem.Video).id).isEqualTo(UniqueId("${AN_EVENT_ID.value}_2")) + } + + private fun aGalleryInfo() = GalleryInfo( + caption = "A caption", + formattedCaption = null, + senderId = A_USER_ID, + senderName = "Alice", + senderAvatar = null, + dateSent = "Today", + dateSentFull = "Today at 12:00", + initialIndex = 0, + ) + + private fun expectedMediaInfo(filename: String, mimeType: String) = MediaInfo( + filename = filename, + fileSize = null, + caption = "A caption", + mimeType = mimeType, + formattedFileSize = "", + fileExtension = filename.substringAfterLast('.', ""), + senderId = A_USER_ID, + senderName = "Alice", + senderAvatar = null, + dateSent = "Today", + dateSentFull = "Today at 12:00", + waveform = null, + duration = null, + ) +} From fd799a3bdf076adead55a68be43936470fcc7cc4 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 29 Jun 2026 11:13:29 +0200 Subject: [PATCH 10/27] Add unit test on TimelineEventContentMapper. --- .../TimelineEventContentMessageLike.kt | 105 ++++++ .../impl/fixtures/fakes/FakeMediaSource.kt | 18 + .../item/event/EventMessageMapperTest.kt | 318 ++++++++++++++++++ 3 files changed, 441 insertions(+) create mode 100644 libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeMediaSource.kt create mode 100644 libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapperTest.kt diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineEventContentMessageLike.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineEventContentMessageLike.kt index 877f2056e9d..0f8a0d0134c 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineEventContentMessageLike.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineEventContentMessageLike.kt @@ -8,12 +8,26 @@ package io.element.android.libraries.matrix.impl.fixtures.factories +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeMediaSource import io.element.android.libraries.matrix.test.A_MESSAGE +import org.matrix.rustcomponents.sdk.AudioInfo +import org.matrix.rustcomponents.sdk.AudioMessageContent +import org.matrix.rustcomponents.sdk.FileInfo +import org.matrix.rustcomponents.sdk.FileMessageContent import org.matrix.rustcomponents.sdk.FormattedBody +import org.matrix.rustcomponents.sdk.GalleryItemType +import org.matrix.rustcomponents.sdk.GalleryMessageContent +import org.matrix.rustcomponents.sdk.ImageInfo +import org.matrix.rustcomponents.sdk.ImageMessageContent +import org.matrix.rustcomponents.sdk.MediaSource import org.matrix.rustcomponents.sdk.MessageLikeEventContent import org.matrix.rustcomponents.sdk.MessageType import org.matrix.rustcomponents.sdk.TextMessageContent import org.matrix.rustcomponents.sdk.TimelineEventContent +import org.matrix.rustcomponents.sdk.UnstableAudioDetailsContent +import org.matrix.rustcomponents.sdk.UnstableVoiceContent +import org.matrix.rustcomponents.sdk.VideoInfo +import org.matrix.rustcomponents.sdk.VideoMessageContent internal fun aRustTimelineEventContentMessageLike( content: MessageLikeEventContent = aRustMessageLikeEventContentRoomMessage(), @@ -42,3 +56,94 @@ internal fun aRustTextMessageContent( body = body, formatted = formatted, ) + +internal fun aRustMessageTypeGallery( + content: GalleryMessageContent = aRustGalleryMessageContent(), +) = MessageType.Gallery(content = content) + +internal fun aRustGalleryMessageContent( + body: String = "A gallery", + formatted: FormattedBody? = null, + itemTypes: List = listOf(aRustGalleryItemTypeImage()), +) = GalleryMessageContent(body = body, formatted = formatted, itemtypes = itemTypes) + +internal fun aRustGalleryItemTypeImage( + content: ImageMessageContent = aRustImageMessageContent(), +) = GalleryItemType.Image(content = content) + +internal fun aRustGalleryItemTypeAudio( + content: AudioMessageContent = aRustAudioMessageContent(), +) = GalleryItemType.Audio(content = content) + +internal fun aRustGalleryItemTypeVideo( + content: VideoMessageContent = aRustVideoMessageContent(), +) = GalleryItemType.Video(content = content) + +internal fun aRustGalleryItemTypeFile( + content: FileMessageContent = aRustFileMessageContent(), +) = GalleryItemType.File(content = content) + +internal fun aRustGalleryItemTypeOther( + itemType: String = "m.unknown", + body: String = "unknown item", +) = GalleryItemType.Other(itemtype = itemType, body = body) + +internal fun aRustImageMessageContent( + filename: String = "image.jpg", + caption: String? = null, + formattedCaption: FormattedBody? = null, + source: MediaSource = FakeMediaSource("mxc://server/image"), + info: ImageInfo? = null, +) = ImageMessageContent( + filename = filename, + caption = caption, + formattedCaption = formattedCaption, + source = source, + info = info, +) + +internal fun aRustAudioMessageContent( + filename: String = "audio.mp3", + caption: String? = null, + formattedCaption: FormattedBody? = null, + source: MediaSource = FakeMediaSource("mxc://server/audio"), + info: AudioInfo? = null, + audio: UnstableAudioDetailsContent? = null, + voice: UnstableVoiceContent? = null, +) = AudioMessageContent( + filename = filename, + caption = caption, + formattedCaption = formattedCaption, + source = source, + info = info, + audio = audio, + voice = voice, +) + +internal fun aRustVideoMessageContent( + filename: String = "video.mp4", + caption: String? = null, + formattedCaption: FormattedBody? = null, + source: MediaSource = FakeMediaSource("mxc://server/video"), + info: VideoInfo? = null, +) = VideoMessageContent( + filename = filename, + caption = caption, + formattedCaption = formattedCaption, + source = source, + info = info, +) + +internal fun aRustFileMessageContent( + filename: String = "document.pdf", + caption: String? = null, + formattedCaption: FormattedBody? = null, + source: MediaSource = FakeMediaSource("mxc://server/file"), + info: FileInfo? = null, +) = FileMessageContent( + filename = filename, + caption = caption, + formattedCaption = formattedCaption, + source = source, + info = info, +) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeMediaSource.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeMediaSource.kt new file mode 100644 index 00000000000..f30ef5a6928 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeMediaSource.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.fakes + +import org.matrix.rustcomponents.sdk.MediaSource +import org.matrix.rustcomponents.sdk.NoHandle + +internal class FakeMediaSource( + private val fakeUrl: String = "mxc://server/media", +) : MediaSource(NoHandle) { + override fun url(): String = fakeUrl + override fun toJson(): String = """{"url":"$fakeUrl"}""" +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapperTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapperTest.kt new file mode 100644 index 00000000000..79491bac1f9 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapperTest.kt @@ -0,0 +1,318 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.timeline.item.event + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.room.location.AssetType +import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.GalleryItemType +import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustAudioMessageContent +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustFileMessageContent +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustGalleryItemTypeAudio +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustGalleryItemTypeFile +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustGalleryItemTypeImage +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustGalleryItemTypeOther +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustGalleryItemTypeVideo +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustGalleryMessageContent +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustImageMessageContent +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustMessageTypeGallery +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustVideoMessageContent +import org.junit.Test +import org.matrix.rustcomponents.sdk.AssetType as RustAssetType +import org.matrix.rustcomponents.sdk.EmoteMessageContent +import org.matrix.rustcomponents.sdk.LocationContent +import org.matrix.rustcomponents.sdk.MessageType +import org.matrix.rustcomponents.sdk.NoticeMessageContent +import org.matrix.rustcomponents.sdk.TextMessageContent +import org.matrix.rustcomponents.sdk.UnstableVoiceContent + +class EventMessageMapperTest { + private val sut = EventMessageMapper() + + @Test + fun `mapMessageType with Text returns TextMessageType`() { + val result = sut.mapMessageType( + MessageType.Text( + content = TextMessageContent(body = "Hello", formatted = null) + ) + ) + assertThat(result).isEqualTo(TextMessageType(body = "Hello", formatted = null)) + } + + @Test + fun `mapMessageType with Notice returns NoticeMessageType`() { + val result = sut.mapMessageType( + MessageType.Notice(content = NoticeMessageContent(body = "A notice", formatted = null)) + ) + assertThat(result).isEqualTo(NoticeMessageType(body = "A notice", formatted = null)) + } + + @Test + fun `mapMessageType with Emote returns EmoteMessageType`() { + val result = sut.mapMessageType( + MessageType.Emote(content = EmoteMessageContent(body = "An emote", formatted = null)) + ) + assertThat(result).isEqualTo(EmoteMessageType(body = "An emote", formatted = null)) + } + + @Test + fun `mapMessageType with Other returns OtherMessageType`() { + val result = sut.mapMessageType( + MessageType.Other(msgtype = "m.custom", body = "custom body") + ) + assertThat(result).isEqualTo(OtherMessageType(msgType = "m.custom", body = "custom body")) + } + + @Test + fun `mapMessageType with Location returns LocationMessageType`() { + val result = sut.mapMessageType( + MessageType.Location( + content = LocationContent( + body = "Location body", + geoUri = "geo:51.5,-0.1", + description = "London", + zoomLevel = null, + asset = RustAssetType.PIN, + ) + ) + ) + assertThat(result).isEqualTo( + LocationMessageType( + body = "Location body", + geoUri = "geo:51.5,-0.1", + description = "London", + assetType = AssetType.PIN, + ) + ) + } + + @Test + fun `mapMessageType with Audio without voice returns AudioMessageType`() { + val result = sut.mapMessageType( + MessageType.Audio(content = aRustAudioMessageContent(filename = "audio.mp3", voice = null)) + ) + assertThat(result).isEqualTo( + AudioMessageType( + filename = "audio.mp3", + caption = null, + formattedCaption = null, + source = MediaSource(url = "mxc://server/audio", json = """{"url":"mxc://server/audio"}"""), + info = null, + ) + ) + } + + @Test + fun `mapMessageType with Audio with voice returns VoiceMessageType`() { + val result = sut.mapMessageType( + MessageType.Audio(content = aRustAudioMessageContent(filename = "voice.ogg", voice = UnstableVoiceContent())) + ) + assertThat(result).isEqualTo( + VoiceMessageType( + filename = "voice.ogg", + caption = null, + formattedCaption = null, + source = MediaSource(url = "mxc://server/audio", json = """{"url":"mxc://server/audio"}"""), + info = null, + details = null, + ) + ) + } + + @Test + fun `mapMessageType with File returns FileMessageType`() { + val result = sut.mapMessageType( + MessageType.File(content = aRustFileMessageContent(filename = "document.pdf")) + ) + assertThat(result).isEqualTo( + FileMessageType( + filename = "document.pdf", + caption = null, + formattedCaption = null, + source = MediaSource(url = "mxc://server/file", json = """{"url":"mxc://server/file"}"""), + info = null, + ) + ) + } + + @Test + fun `mapMessageType with Image returns ImageMessageType`() { + val result = sut.mapMessageType( + MessageType.Image(content = aRustImageMessageContent(filename = "image.jpg")) + ) + assertThat(result).isEqualTo( + ImageMessageType( + filename = "image.jpg", + caption = null, + formattedCaption = null, + source = MediaSource(url = "mxc://server/image", json = """{"url":"mxc://server/image"}"""), + info = null, + ) + ) + } + + @Test + fun `mapMessageType with Video returns VideoMessageType`() { + val result = sut.mapMessageType( + MessageType.Video(content = aRustVideoMessageContent(filename = "video.mp4")) + ) + assertThat(result).isEqualTo( + VideoMessageType( + filename = "video.mp4", + caption = null, + formattedCaption = null, + source = MediaSource(url = "mxc://server/video", json = """{"url":"mxc://server/video"}"""), + info = null, + ) + ) + } + + @Test + fun `mapMessageType with Gallery with Image item returns GalleryMessageType with Image`() { + val result = sut.mapMessageType( + aRustMessageTypeGallery( + content = aRustGalleryMessageContent( + body = "A gallery", + itemTypes = listOf(aRustGalleryItemTypeImage(aRustImageMessageContent(filename = "image.jpg"))), + ) + ) + ) + assertThat(result).isEqualTo( + GalleryMessageType( + body = "A gallery", + formatted = null, + items = listOf( + GalleryItemType.Image( + content = ImageMessageType( + filename = "image.jpg", + caption = null, + formattedCaption = null, + source = MediaSource(url = "mxc://server/image", json = """{"url":"mxc://server/image"}"""), + info = null, + ) + ) + ), + ) + ) + } + + @Test + fun `mapMessageType with Gallery with Audio item returns GalleryMessageType with Audio`() { + val result = sut.mapMessageType( + aRustMessageTypeGallery( + content = aRustGalleryMessageContent( + itemTypes = listOf(aRustGalleryItemTypeAudio(aRustAudioMessageContent(filename = "audio.mp3"))) + ) + ) + ) + assertThat(result).isEqualTo( + GalleryMessageType( + body = "A gallery", + formatted = null, + items = listOf( + GalleryItemType.Audio( + content = AudioMessageType( + filename = "audio.mp3", + caption = null, + formattedCaption = null, + source = MediaSource(url = "mxc://server/audio", json = """{"url":"mxc://server/audio"}"""), + info = null, + ) + ) + ), + ) + ) + } + + @Test + fun `mapMessageType with Gallery with Video item returns GalleryMessageType with Video`() { + val result = sut.mapMessageType( + aRustMessageTypeGallery( + content = aRustGalleryMessageContent( + itemTypes = listOf(aRustGalleryItemTypeVideo(aRustVideoMessageContent(filename = "video.mp4"))) + ) + ) + ) + assertThat(result).isEqualTo( + GalleryMessageType( + body = "A gallery", + formatted = null, + items = listOf( + GalleryItemType.Video( + content = VideoMessageType( + filename = "video.mp4", + caption = null, + formattedCaption = null, + source = MediaSource(url = "mxc://server/video", json = """{"url":"mxc://server/video"}"""), + info = null, + ) + ) + ), + ) + ) + } + + @Test + fun `mapMessageType with Gallery with File item returns GalleryMessageType with File`() { + val result = sut.mapMessageType( + aRustMessageTypeGallery( + content = aRustGalleryMessageContent( + itemTypes = listOf(aRustGalleryItemTypeFile(aRustFileMessageContent(filename = "document.pdf"))) + ) + ) + ) + assertThat(result).isEqualTo( + GalleryMessageType( + body = "A gallery", + formatted = null, + items = listOf( + GalleryItemType.File( + content = FileMessageType( + filename = "document.pdf", + caption = null, + formattedCaption = null, + source = MediaSource(url = "mxc://server/file", json = """{"url":"mxc://server/file"}"""), + info = null, + ) + ) + ), + ) + ) + } + + @Test + fun `mapMessageType with Gallery with Other item returns GalleryMessageType with Other`() { + val result = sut.mapMessageType( + aRustMessageTypeGallery( + content = aRustGalleryMessageContent( + itemTypes = listOf(aRustGalleryItemTypeOther(itemType = "m.custom", body = "custom item")) + ) + ) + ) + assertThat(result).isEqualTo( + GalleryMessageType( + body = "A gallery", + formatted = null, + items = listOf( + GalleryItemType.Other(itemType = "m.custom", body = "custom item") + ), + ) + ) + } +} From ad841222850c0567628beed4cc38b2de32a4b8cf Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 29 Jun 2026 11:21:02 +0200 Subject: [PATCH 11/27] Revert change on TimelineItemVideoView, it was dead code. --- .../components/event/TimelineItemVideoView.kt | 89 +++---------------- 1 file changed, 13 insertions(+), 76 deletions(-) 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 35cdc9e6c32..8d1ef18f39f 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,19 +14,14 @@ 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 @@ -36,13 +31,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.text.style.TextOverflow +import androidx.compose.ui.semantics.hideFromAccessibility +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage @@ -61,6 +56,7 @@ 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 @@ -69,7 +65,6 @@ 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 @@ -84,7 +79,6 @@ fun TimelineItemVideoView( onLinkLongClick: (Link) -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, - isGalleryVideo: Boolean = false, ) { val isTalkbackActive = isTalkbackActive() val a11yLabel = stringResource(CommonStrings.common_video) @@ -136,71 +130,16 @@ fun TimelineItemVideoView( onState = { isLoaded = it is AsyncImagePainter.State.Success }, ) - 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) - ) - } + 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() } + ) } } } @@ -245,7 +184,6 @@ internal fun TimelineItemVideoViewPreview(@PreviewParameter(TimelineItemVideoCon onLinkClick = {}, onLinkLongClick = {}, onContentLayoutChange = {}, - isGalleryVideo = false, ) } @@ -261,7 +199,6 @@ internal fun TimelineItemVideoViewHideMediaContentPreview() = ElementPreview { onLinkClick = {}, onLinkLongClick = {}, onContentLayoutChange = {}, - isGalleryVideo = false, ) } From 73ef038035a46f7003388da60fb72671f2e1ead9 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 29 Jun 2026 11:31:21 +0200 Subject: [PATCH 12/27] Preview: add video item in the list --- .../TimelineItemAttachmentsContentProvider.kt | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 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 032229da828..9bc554057f1 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 @@ -27,14 +27,6 @@ class TimelineItemAttachmentsContentProvider : PreviewParameterProvider Date: Mon, 29 Jun 2026 11:36:20 +0200 Subject: [PATCH 13/27] Remove duplicated fixtures. --- ...eItemEventContentForGalleryViewProvider.kt | 4 +- .../TimelineItemGalleryContentProvider.kt | 31 +++++++++++-- .../event/TimelineItemEventContentProvider.kt | 43 ------------------- 3 files changed, 29 insertions(+), 49 deletions(-) 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 63034d56e12..ed790da8a2c 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,11 +8,11 @@ package io.element.android.features.messages.impl.timeline.components import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.messages.impl.timeline.components.event.aGalleryItem +import io.element.android.features.messages.impl.timeline.components.event.aTimelineItemGalleryContent import io.element.android.features.messages.impl.timeline.model.event.GalleryItem import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent -import io.element.android.features.messages.impl.timeline.model.event.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 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 2a0c10f518f..fe7de6a7f62 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 @@ -10,8 +10,8 @@ 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 kotlinx.collections.immutable.toImmutableList import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -93,15 +93,38 @@ class TimelineItemGalleryContentProvider : PreviewParameterProvider = listOf( + aGalleryItem(), + aGalleryItem(), + aGalleryItem(), + aGalleryItem(), + ), +) = TimelineItemGalleryContent( + body = body, + caption = caption, + formattedCaption = null, + isEdited = false, + items = items.toImmutableList(), +) + +fun aGalleryItem( + filename: String = "photo.jpg", type: GalleryItem.Type = GalleryItem.Type.Image, width: Int = 400, height: Int = 300, duration: Duration = Duration.ZERO, ): GalleryItem { return GalleryItem( - filename = "photo.jpg", - mimeType = "image/jpeg", + filename = filename, + mimeType = when (type) { + GalleryItem.Type.Video -> "video/mp4" + GalleryItem.Type.Audio -> "audio/mpeg" + GalleryItem.Type.File -> "application/pdf" + GalleryItem.Type.Image -> "image/jpeg" + }, mediaSource = MediaSource(url = "", json = ""), thumbnailSource = null, width = width, 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 454a1d17574..76502b29750 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,7 +17,6 @@ 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( @@ -115,48 +114,6 @@ fun aTimelineItemStateEventContent( 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.toImmutableList(), -) - -fun aGalleryItem( - filename: String = "photo.jpg", - width: Int = 400, - height: Int = 300, - type: GalleryItem.Type = GalleryItem.Type.Image, - duration: Duration = Duration.ZERO, -) = GalleryItem( - filename = filename, - mimeType = when (type) { - GalleryItem.Type.Video -> "video/mp4" - GalleryItem.Type.Audio -> "audio/mpeg" - GalleryItem.Type.File -> "application/pdf" - GalleryItem.Type.Image -> "image/jpeg" - }, - mediaSource = MediaSource(url = "", json = ""), - type = type, - thumbnailSource = null, - width = width, - height = height, - thumbnailWidth = width, - thumbnailHeight = height, - blurhash = null, - duration = duration, -) - fun aTimelineItemAttachmentsContent( body: String = "Attachments", caption: String? = null, From 79163b68b5f41b5b899ee6ed258a17582c2c27a8 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 29 Jun 2026 11:50:28 +0200 Subject: [PATCH 14/27] Fix quality issues --- .../matrix/impl/timeline/item/event/EventMessageMapperTest.kt | 2 +- .../mediaviewer/impl/datasource/EventItemFactoryTest.kt | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapperTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapperTest.kt index 79491bac1f9..c74f67bfe66 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapperTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapperTest.kt @@ -34,13 +34,13 @@ import io.element.android.libraries.matrix.impl.fixtures.factories.aRustImageMes import io.element.android.libraries.matrix.impl.fixtures.factories.aRustMessageTypeGallery import io.element.android.libraries.matrix.impl.fixtures.factories.aRustVideoMessageContent import org.junit.Test -import org.matrix.rustcomponents.sdk.AssetType as RustAssetType import org.matrix.rustcomponents.sdk.EmoteMessageContent import org.matrix.rustcomponents.sdk.LocationContent import org.matrix.rustcomponents.sdk.MessageType import org.matrix.rustcomponents.sdk.NoticeMessageContent import org.matrix.rustcomponents.sdk.TextMessageContent import org.matrix.rustcomponents.sdk.UnstableVoiceContent +import org.matrix.rustcomponents.sdk.AssetType as RustAssetType class EventMessageMapperTest { private val sut = EventMessageMapper() diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactoryTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactoryTest.kt index de166803860..7d0018f1207 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactoryTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactoryTest.kt @@ -12,6 +12,7 @@ import com.google.common.truth.Truth.assertThat import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.dateformatter.test.FakeDateFormatter +import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.media.AudioDetails import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.FileInfo @@ -20,7 +21,6 @@ import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.notification.CallIntent import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem -import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType @@ -60,6 +60,7 @@ import kotlinx.collections.immutable.toImmutableList import org.junit.Test import kotlin.time.Duration.Companion.seconds +@Suppress("LargeClass") class EventItemFactoryTest { @Test fun `create check all null cases`() { From 5f0461b1cdbca4cd719e1d2ee0c69d90448d4323 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Mon, 29 Jun 2026 10:04:39 +0000 Subject: [PATCH 15/27] Update screenshots --- ...ponents.event_TimelineItemAttachmentsListView_Day_0_en.png | 4 ++-- ...ponents.event_TimelineItemAttachmentsListView_Day_1_en.png | 4 ++-- ...nents.event_TimelineItemAttachmentsListView_Night_0_en.png | 4 ++-- ...nents.event_TimelineItemAttachmentsListView_Night_1_en.png | 4 ++-- ...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 ++-- .../features.messages.impl.timeline_TimelineView_Day_3_en.png | 4 ++-- ...eatures.messages.impl.timeline_TimelineView_Night_3_en.png | 4 ++-- 16 files changed, 32 insertions(+), 32 deletions(-) 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 index 8afe26e76bb..59219a4d999 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c4390e005f2ee64c62795578b87bce877d6445c30173bc1c1e45eea6db117d9b -size 29822 +oid sha256:bb3b4e2147056056ad80681a4738aaf8e16407c14af12a1fd556431ff732f54e +size 15377 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Day_1_en.png index 3fae9947ca2..aa00f453559 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:21f1d31507e7ffa764c9f58ce7e0cb5624adb05e49d735f7d2a549d6b03c9da6 -size 16174 +oid sha256:aa4034a06aa6e8d24806b640d9c6604bf4cffc5ec5664d2b9b189e6c39850ce0 +size 42543 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 index b08cbda1fee..6c7d35be626 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:04149afc743a7f1f651db40aa4561847c81c51478aee9b76c7c9b0fab88cac5a -size 29014 +oid sha256:75fce2e957c6ba2da818557c4d02c4f96cca05541a02d51b36b72017a88f81fe +size 14743 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Night_1_en.png index 54d451ecea0..29714c40b33 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7078a50ab56312e7472054c3bf0fc816753c887091c1887ad4ccd7851ca8db24 -size 15478 +oid sha256:9b777363de123a6de81360e4ee9c5066c8953ac103c52349979e4c3fdca1e20c +size 41699 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 95c3a57373a..9238837c39e 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:a1ded2aad1e00ff19022cfd44bf79cfd9f4ab8ba8f6c32b620e98ea418ad3155 -size 274099 +oid sha256:eb6b6a0c8d9891776097e0e5448c017dd9a943fe30b99735300d5107ff29ff47 +size 274673 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 d339251b45f..861f05e267e 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:9a107f72769dba5206b02057fd16561f45bd915b9ef18c9057be5e3ba33aea41 -size 505699 +oid sha256:b1e7bab4b587727f5d6f2da55db4bd2d4c35e0b12814a9ddac1f1ce176a687b2 +size 506341 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 2e218482449..c48e8c45b6b 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:c65b393e0c2ea85323a0ae3da77bd1075e1543ebd1ff8b8d64defd084929b4eb -size 383073 +oid sha256:57c4e5cd10840faa1ea17b9758b6b189eeb012f0a22a218d64c73d4c92ae3482 +size 383712 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 95c3a57373a..9238837c39e 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:a1ded2aad1e00ff19022cfd44bf79cfd9f4ab8ba8f6c32b620e98ea418ad3155 -size 274099 +oid sha256:eb6b6a0c8d9891776097e0e5448c017dd9a943fe30b99735300d5107ff29ff47 +size 274673 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 95c3a57373a..9238837c39e 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:a1ded2aad1e00ff19022cfd44bf79cfd9f4ab8ba8f6c32b620e98ea418ad3155 -size 274099 +oid sha256:eb6b6a0c8d9891776097e0e5448c017dd9a943fe30b99735300d5107ff29ff47 +size 274673 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 d339251b45f..861f05e267e 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:9a107f72769dba5206b02057fd16561f45bd915b9ef18c9057be5e3ba33aea41 -size 505699 +oid sha256:b1e7bab4b587727f5d6f2da55db4bd2d4c35e0b12814a9ddac1f1ce176a687b2 +size 506341 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 2e218482449..c48e8c45b6b 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:c65b393e0c2ea85323a0ae3da77bd1075e1543ebd1ff8b8d64defd084929b4eb -size 383073 +oid sha256:57c4e5cd10840faa1ea17b9758b6b189eeb012f0a22a218d64c73d4c92ae3482 +size 383712 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 95c3a57373a..9238837c39e 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:a1ded2aad1e00ff19022cfd44bf79cfd9f4ab8ba8f6c32b620e98ea418ad3155 -size 274099 +oid sha256:eb6b6a0c8d9891776097e0e5448c017dd9a943fe30b99735300d5107ff29ff47 +size 274673 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 48894c2f21e..f792a1663ab 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:85be731a87510f5825fd37c24f9057d86592b3d43ce93e2bcb511a20f80f6fca -size 606225 +oid sha256:e3bb005a88e9a4d2bb850478ee8ac2c4f31d32625bd6d2abf455fade461cd399 +size 607806 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 aad5803218f..8733429e34c 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:56fc2ff0f7f6f29f1696ac6c5300b04a1a00526fb95f380f4dd917033415c7aa -size 605173 +oid sha256:8bb505fe6d67669339c9366f8ee5bc75974e223837d95e76e637455a51c18711 +size 606787 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 67265ad1811..b9723dd5c4f 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:923e46e5de89aba25a414e7ff434b82e0e59b3f38d32adcf72234901bfd49c59 -size 487648 +oid sha256:723238ad84c9925eb0edbbf225b50a03bf29e5671777d9f03eba6b910ee00fcf +size 488705 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 7a07b8c1bf8..6ab414ab0b2 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:85400acd84edfd9968e013f4cd1276b6f4834a81634b9bd2ba17f88b3c1191f8 -size 480656 +oid sha256:80e198bd8cf523c785de4a66c4c22cecb22d1040377bdfd66eeed821a4281592 +size 481590 From 5d0d8bedcd83d37a994c5a11d881423510d8c8c8 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 29 Jun 2026 14:28:14 +0200 Subject: [PATCH 16/27] Fix name regarding naming convention --- .../factories/TimelineEventContentMessageLike.kt | 10 +++++----- .../{FakeMediaSource.kt => FakeFfiMediaSource.kt} | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) rename libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/{FakeMediaSource.kt => FakeFfiMediaSource.kt} (94%) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineEventContentMessageLike.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineEventContentMessageLike.kt index 0f8a0d0134c..96303a257b2 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineEventContentMessageLike.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineEventContentMessageLike.kt @@ -8,7 +8,7 @@ package io.element.android.libraries.matrix.impl.fixtures.factories -import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeMediaSource +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiMediaSource import io.element.android.libraries.matrix.test.A_MESSAGE import org.matrix.rustcomponents.sdk.AudioInfo import org.matrix.rustcomponents.sdk.AudioMessageContent @@ -92,7 +92,7 @@ internal fun aRustImageMessageContent( filename: String = "image.jpg", caption: String? = null, formattedCaption: FormattedBody? = null, - source: MediaSource = FakeMediaSource("mxc://server/image"), + source: MediaSource = FakeFfiMediaSource("mxc://server/image"), info: ImageInfo? = null, ) = ImageMessageContent( filename = filename, @@ -106,7 +106,7 @@ internal fun aRustAudioMessageContent( filename: String = "audio.mp3", caption: String? = null, formattedCaption: FormattedBody? = null, - source: MediaSource = FakeMediaSource("mxc://server/audio"), + source: MediaSource = FakeFfiMediaSource("mxc://server/audio"), info: AudioInfo? = null, audio: UnstableAudioDetailsContent? = null, voice: UnstableVoiceContent? = null, @@ -124,7 +124,7 @@ internal fun aRustVideoMessageContent( filename: String = "video.mp4", caption: String? = null, formattedCaption: FormattedBody? = null, - source: MediaSource = FakeMediaSource("mxc://server/video"), + source: MediaSource = FakeFfiMediaSource("mxc://server/video"), info: VideoInfo? = null, ) = VideoMessageContent( filename = filename, @@ -138,7 +138,7 @@ internal fun aRustFileMessageContent( filename: String = "document.pdf", caption: String? = null, formattedCaption: FormattedBody? = null, - source: MediaSource = FakeMediaSource("mxc://server/file"), + source: MediaSource = FakeFfiMediaSource("mxc://server/file"), info: FileInfo? = null, ) = FileMessageContent( filename = filename, diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeMediaSource.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiMediaSource.kt similarity index 94% rename from libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeMediaSource.kt rename to libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiMediaSource.kt index f30ef5a6928..7dbcb1d7625 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeMediaSource.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiMediaSource.kt @@ -10,7 +10,7 @@ package io.element.android.libraries.matrix.impl.fixtures.fakes import org.matrix.rustcomponents.sdk.MediaSource import org.matrix.rustcomponents.sdk.NoHandle -internal class FakeMediaSource( +internal class FakeFfiMediaSource( private val fakeUrl: String = "mxc://server/media", ) : MediaSource(NoHandle) { override fun url(): String = fakeUrl From f91fd4b53547912a922763102ad39840fa1876b8 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 30 Jun 2026 11:35:20 +0200 Subject: [PATCH 17/27] Rename method to sendMedia --- .../impl/attachments/preview/AttachmentsPreviewPresenter.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 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 3ea9fc30e10..5f7939f826d 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 @@ -246,7 +246,7 @@ class AttachmentsPreviewPresenter( // 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) { - sendGalleryPreProcessed( + sendMedia( mediaUploadInfos = allMediaUploadInfos, caption = caption, sendActionState = sendActionState, @@ -478,7 +478,7 @@ class AttachmentsPreviewPresenter( sendActionState.value = SendActionState.Idle } - private suspend fun sendGalleryPreProcessed( + private suspend fun sendMedia( mediaUploadInfos: List, caption: String?, sendActionState: MutableState, From 96e1c93f38a056ffcdadc546f2c78ee5078f758b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 30 Jun 2026 11:50:58 +0200 Subject: [PATCH 18/27] Introduce derived state editedAttachments --- .../preview/AttachmentsPreviewPresenter.kt | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 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 5f7939f826d..ef85141df1e 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 @@ -11,6 +11,7 @@ package io.element.android.features.messages.impl.attachments.preview import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -121,6 +122,12 @@ class AttachmentsPreviewPresenter( ) } + val editedAttachments by remember { + derivedStateOf { + attachmentsAndEdits.map { it.attachment }.toImmutableList() + } + } + var preprocessMediaJob by remember { mutableStateOf(null) } val firstMediaAttachment = attachments.first() as Attachment.Media @@ -148,7 +155,7 @@ class AttachmentsPreviewPresenter( preprocessMediaJob?.cancel() preprocessMediaJob = coroutineScope.launch(dispatchers.io) { preProcessAttachments( - attachments = attachmentsAndEdits.map { it.attachment }, + attachments = editedAttachments, mediaOptimizationConfig = config, displayProgress = false, sendActionState = sendActionState, @@ -206,7 +213,7 @@ class AttachmentsPreviewPresenter( ) preprocessMediaJob = coroutineScope.launch(dispatchers.io) { preProcessAttachments( - attachments = attachmentsAndEdits.map { it.attachment }, + attachments = editedAttachments, mediaOptimizationConfig = config, displayProgress = true, sendActionState = sendActionState, @@ -221,7 +228,7 @@ class AttachmentsPreviewPresenter( ) preprocessMediaJob = coroutineScope.launch(dispatchers.io) { preProcessAttachments( - attachments = attachmentsAndEdits.map { it.attachment }, + attachments = editedAttachments, mediaOptimizationConfig = config, displayProgress = true, sendActionState = sendActionState, @@ -273,7 +280,7 @@ class AttachmentsPreviewPresenter( // Dismiss the screen dismiss( - attachments = attachmentsAndEdits.map { it.attachment }, + attachments = editedAttachments, sendActionState = sendActionState, editedTempFiles = editedTempFiles, ) @@ -395,7 +402,7 @@ class AttachmentsPreviewPresenter( } return AttachmentsPreviewState( - attachments = attachmentsAndEdits.map { it.attachment }.toImmutableList(), + attachments = editedAttachments, imageEditorState = imageEditorState, canEditImage = canEditImage, isApplyingImageEdits = isApplyingImageEdits, From 99343926ffe06f154d60479c5c0fe46f13159cec Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 30 Jun 2026 12:00:12 +0200 Subject: [PATCH 19/27] Extract SingleItemPreviewRenderer, make it a class and rename it to SampleMediaRenderer --- .../preview/AttachmentsPreviewView.kt | 19 ++--------- .../preview/SampleMediaRenderer.kt | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+), 17 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/SampleMediaRenderer.kt 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 5a39ddf1f24..70e1d40f3db 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 @@ -13,7 +13,6 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -39,7 +38,6 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics @@ -77,8 +75,6 @@ import io.element.android.libraries.designsystem.theme.components.Switch import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.floatingDateBadgeBackground -import io.element.android.libraries.designsystem.utils.CommonDrawables -import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import io.element.android.libraries.textcomposer.TextComposer @@ -93,17 +89,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlin.time.Duration.Companion.milliseconds -private val SingleItemPreviewRenderer = object : LocalMediaRenderer { - @Composable - override fun Render(localMedia: LocalMedia) { - Image( - painter = painterResource(id = CommonDrawables.sample_background), - modifier = Modifier.fillMaxSize(), - contentDescription = null, - ) - } -} - /** * Ref: https://www.figma.com/design/zftpgS6LjiczobJZ1GUNpt/Updates-to-Media---File-Upload?node-id=51-3514 */ @@ -571,7 +556,7 @@ private fun AttachmentsPreviewBottomActions( internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = ElementPreviewDark { AttachmentsPreviewView( state = state, - localMediaRenderer = SingleItemPreviewRenderer, + localMediaRenderer = SampleMediaRenderer(), ) } @@ -580,7 +565,7 @@ internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewS internal fun AttachmentsPreviewGalleryViewPreview() = ElementPreviewDark { AttachmentsPreviewView( state = anAttachmentsPreviewGalleryState(), - localMediaRenderer = SingleItemPreviewRenderer, + localMediaRenderer = SampleMediaRenderer(), ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/SampleMediaRenderer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/SampleMediaRenderer.kt new file mode 100644 index 00000000000..f0fd834ce66 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/SampleMediaRenderer.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.attachments.preview + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import io.element.android.libraries.designsystem.utils.CommonDrawables +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer + +/** + * An implementation of [LocalMediaRenderer] that displays a sample background image. + * To be used for Previews only. + */ +class SampleMediaRenderer : LocalMediaRenderer { + @Composable + override fun Render(localMedia: LocalMedia) { + Image( + painter = painterResource(id = CommonDrawables.sample_background), + modifier = Modifier.fillMaxSize(), + contentDescription = null, + ) + } +} From 89b4722a586c6c9c23662f7efc0320d69e6a96c5 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 30 Jun 2026 12:06:34 +0200 Subject: [PATCH 20/27] Restore previous code. --- .../impl/attachments/preview/AttachmentsPreviewView.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 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 70e1d40f3db..1b36c469fef 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 @@ -278,8 +278,8 @@ private fun AttachmentPreviewContent( ) { Box( modifier = Modifier - .fillMaxSize() - .weight(1f) + .weight(1f), + contentAlignment = Alignment.Center, ) { if (state.isGallery) { val pagerState = rememberPagerState( From 29743d3c89b16aeef6fc6d6223f1918aeeb08496 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 30 Jun 2026 12:16:04 +0200 Subject: [PATCH 21/27] Some renaming for clarity. --- .../factories/event/TimelineItemContentMessageFactory.kt | 6 +++--- .../impl/timeline/model/event/TimelineItemGalleryContent.kt | 2 +- 2 files changed, 4 insertions(+), 4 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 1cd911cf356..040d0450ff4 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 @@ -337,10 +337,10 @@ class TimelineItemContentMessageFactory( } } val hasPreviews = galleryItems.any { it.thumbnailSource != null } - val isMediaGallery = galleryItems.all { item -> - item.type.isMedia() + val isOnlyVisualMedia = galleryItems.all { item -> + item.type.isVisualMedia() } - if (isMediaGallery && hasPreviews) { + if (isOnlyVisualMedia && hasPreviews) { TimelineItemGalleryContent( body = messageType.body, caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() }, 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 be19228aed3..f376a2a00a2 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 @@ -43,7 +43,7 @@ data class GalleryItem( Audio, File; - fun isMedia() = this in setOf(Image, Video) + fun isVisualMedia() = this in setOf(Image, Video) } val thumbnailMediaRequestData: MediaRequestData by lazy { From 5ca1435e9ed05eced3d5c5e2939291042db7c4d6 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 30 Jun 2026 12:26:43 +0200 Subject: [PATCH 22/27] Remove dead code. --- .../matrix/impl/media/FormattedBody.kt | 24 ------------------- 1 file changed, 24 deletions(-) delete mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/FormattedBody.kt 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 deleted file mode 100644 index b431e82b264..00000000000 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/FormattedBody.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (c) 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.matrix.impl.media - -import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody -import org.matrix.rustcomponents.sdk.FormattedBody as RustFormattedBody -import org.matrix.rustcomponents.sdk.MessageFormat as RustMessageFormat - -fun FormattedBody.map(): RustFormattedBody = RustFormattedBody( - format = format.map(), - body = body, -) - -private fun io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat.map(): RustMessageFormat { - return when (this) { - io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat.HTML -> RustMessageFormat.Html - io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat.UNKNOWN -> RustMessageFormat.Unknown("") - } -} From 16eec4d024c0d22a74d393313a8e82ce9f5bfcac Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 30 Jun 2026 12:33:10 +0200 Subject: [PATCH 23/27] Simplify code --- .../datasource/TimelineMediaItemsFactory.kt | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) 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 1d262962694..86f77307807 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)) { + val items = if (cachedItems != null && currentTimelineItem.isUnchanged(previousTimelineItems)) { cachedItems } else { eventItemFactory.create(currentTimelineItem).also { newItems -> @@ -65,16 +65,15 @@ class TimelineMediaItemsFactory( _timelineItems.emit(newTimelineItemStates.toImmutableList()) } } +} - private fun isItemUnchanged( - currentTimelineItem: MatrixTimelineItem.Event, - previousItems: List, - ): 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 - } +private fun MatrixTimelineItem.Event.isUnchanged( + previousItems: List, +): Boolean { + val previousItem = previousItems + .filterIsInstance() + .find { it.uniqueId == uniqueId } + return previousItem != null && + previousItem.event.eventId == event.eventId && + previousItem.event.timestamp == event.timestamp } From 6cce74db88efdb2be2650c1e831ff7525efa43ad Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 30 Jun 2026 16:37:58 +0200 Subject: [PATCH 24/27] [Gallery] Set media optimation user choice per media --- .../preview/AttachmentsPreviewPresenter.kt | 152 +++++++++++------- .../AttachmentsPreviewStateProvider.kt | 1 + .../preview/AttachmentsPreviewView.kt | 2 +- ...faultMediaOptimizationSelectorPresenter.kt | 3 + .../MediaOptimizationSelectorPresenter.kt | 1 + .../video/MediaOptimizationSelectorState.kt | 2 + .../AttachmentsPreviewPresenterTest.kt | 21 +++ ...tMediaOptimizationSelectorPresenterTest.kt | 2 + ...diaOptimizationSelectorPresenterFactory.kt | 3 +- 9 files changed, 126 insertions(+), 61 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 ef85141df1e..d60de004d90 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 @@ -28,6 +28,8 @@ import io.element.android.features.messages.impl.attachments.preview.imageeditor import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEditorState import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEdits import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorPresenter +import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState +import io.element.android.features.messages.impl.attachments.video.VideoCompressionPresetSelector import io.element.android.libraries.androidutils.file.TemporaryUriDeleter import io.element.android.libraries.androidutils.file.safeDelete import io.element.android.libraries.androidutils.hash.hash @@ -69,6 +71,7 @@ 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, @@ -97,9 +100,7 @@ class AttachmentsPreviewPresenter( val sendActionState = remember { mutableStateOf(SendActionState.Idle) } - 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 canEditImage by remember { mutableStateOf(false) } var imageEditorState by remember { mutableStateOf(null) } var isApplyingImageEdits by remember { mutableStateOf(false) } var displayImageEditError by remember { mutableStateOf(false) } @@ -130,37 +131,57 @@ class AttachmentsPreviewPresenter( var preprocessMediaJob by remember { mutableStateOf(null) } - val firstMediaAttachment = attachments.first() as Attachment.Media - val mediaOptimizationSelectorPresenter = remember { - mediaOptimizationSelectorPresenterFactory.create( - localMedia = firstMediaAttachment.localMedia, - sendAsFile = firstMediaAttachment.sendAsFile, - ) + val mediaOptimizationSelectorPresenters = remember { + attachments + .filterIsInstance() + .mapIndexed { index, attachment -> + mediaOptimizationSelectorPresenterFactory.create( + index = index, + localMedia = attachment.localMedia, + sendAsFile = attachment.sendAsFile, + ) + } } - val mediaOptimizationSelectorState by rememberUpdatedState(mediaOptimizationSelectorPresenter.present()) + val mediaOptimizationSelectorStates by rememberUpdatedState( + mediaOptimizationSelectorPresenters.map { + it.present() + }.toImmutableList() + ) val observableSendState = snapshotFlow { sendActionState.value } var displayFileTooLargeError by remember { mutableStateOf(false) } - LaunchedEffect(mediaOptimizationSelectorState.displayMediaSelectorViews, mediaOptimizationSelectorState.selectedVideoPreset) { - if (mediaOptimizationSelectorState.displayMediaSelectorViews == false && - imageEditorState == null && - !isApplyingImageEdits + LaunchedEffect( + mediaOptimizationSelectorStates, + imageEditorState, + isApplyingImageEdits, + editedAttachments, + ) { + if (mediaOptimizationSelectorStates.any { it.displayMediaSelectorViews == true } || + imageEditorState != null || + isApplyingImageEdits ) { - val config = MediaOptimizationConfig( - compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled ?: mediaOptimizationConfigProvider.get().compressImages, - videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: mediaOptimizationConfigProvider.get().videoCompressionPreset, + // If any of the media optimization selectors are displayed, we don't want to pre-process the media yet + return@LaunchedEffect + } + // If the media optimization selector is not displayed, we can pre-process the media + // to prepare it for sending. This is done to avoid blocking the UI thread when the + // user clicks on the send button. + val configs = mediaOptimizationSelectorStates.mapIndexed { index, mediaOptimizationSelectorState -> + getAutoPreprocessMediaOptimizationConfig( + mediaAttachment = editedAttachments[index] as Attachment.Media, + mediaOptimizationSelectorState = mediaOptimizationSelectorState, + ) + } + preprocessMediaJob?.cancel() + preprocessMediaJob = coroutineScope.launch(dispatchers.io) { + preProcessAttachments( + attachments = editedAttachments, + mediaOptimizationConfigs = configs, + displayProgress = false, + sendActionState = sendActionState, ) - preprocessMediaJob?.cancel() - preprocessMediaJob = coroutineScope.launch(dispatchers.io) { - preProcessAttachments( - attachments = editedAttachments, - mediaOptimizationConfig = config, - displayProgress = false, - sendActionState = sendActionState, - ) - } } } @@ -171,7 +192,9 @@ class AttachmentsPreviewPresenter( } } - val maxUploadSize = mediaOptimizationSelectorState.maxUploadSize.dataOrNull() + val maxUploadSize = mediaOptimizationSelectorStates.firstNotNullOfOrNull { + it.maxUploadSize.dataOrNull() + } LaunchedEffect(maxUploadSize) { if (maxUploadSize != null) { // If file size is not known, we're permissive and allow sending. The SDK will cancel the upload if needed. @@ -192,11 +215,13 @@ class AttachmentsPreviewPresenter( } } - val videoSizeEstimations = mediaOptimizationSelectorState.videoSizeEstimations.dataOrNull() - LaunchedEffect(videoSizeEstimations) { - if (videoSizeEstimations != null) { - // Check if the video size estimations are too large for the max upload size - displayFileTooLargeError = videoSizeEstimations.none { it.canUpload } + mediaOptimizationSelectorStates.forEach { mediaOptimizationSelectorState -> + val videoSizeEstimations = mediaOptimizationSelectorState.videoSizeEstimations.dataOrNull() + LaunchedEffect(videoSizeEstimations) { + if (videoSizeEstimations != null) { + // Check if the video size estimations are too large for the max upload size + displayFileTooLargeError = videoSizeEstimations.none { it.canUpload } + } } } @@ -204,32 +229,19 @@ class AttachmentsPreviewPresenter( when (event) { is AttachmentsPreviewEvent.SendAttachment -> { ongoingSendAttachmentJob.value = coroutineScope.launch { - // If the media optimization selector is displayed, we need to wait for the user to select the options - // before we can pre-process the media. - if (mediaOptimizationSelectorState.displayMediaSelectorViews == true) { - val config = MediaOptimizationConfig( - compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled == true, - videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: VideoCompressionPreset.STANDARD, - ) - preprocessMediaJob = coroutineScope.launch(dispatchers.io) { - preProcessAttachments( - attachments = editedAttachments, - mediaOptimizationConfig = config, - displayProgress = true, - sendActionState = sendActionState, + if (preprocessMediaJob?.isActive != true && sendActionState.value !is SendActionState.Sending.ReadyToUpload) { + val configs = mediaOptimizationSelectorStates.map { + MediaOptimizationConfig( + compressImages = it.isImageOptimizationEnabled + ?: mediaOptimizationConfigProvider.get().compressImages, + videoCompressionPreset = it.selectedVideoPreset + ?: mediaOptimizationConfigProvider.get().videoCompressionPreset, ) } - } 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 = editedAttachments, - mediaOptimizationConfig = config, + mediaOptimizationConfigs = configs, displayProgress = true, sendActionState = sendActionState, ) @@ -350,7 +362,7 @@ class AttachmentsPreviewPresenter( if (!pendingState.edits.hasChanges) { editedTempFiles[currentIndex]?.safeDelete() editedTempFiles = editedTempFiles - currentIndex - currentAttachment = attachmentsAndEdits[currentIndex].attachment + val currentAttachment = attachmentsAndEdits[currentIndex].attachment attachmentsAndEdits = attachmentsAndEdits.toMutableList().also { it[currentIndex] = AttachmentAndEdits( currentAttachment, @@ -374,7 +386,7 @@ class AttachmentsPreviewPresenter( onSuccess = { editedMedia -> editedTempFiles[currentIndex]?.safeDelete() editedTempFiles = editedTempFiles + (currentIndex to editedMedia.file) - currentAttachment = Attachment.Media(editedMedia.localMedia) + val currentAttachment = Attachment.Media(editedMedia.localMedia) attachmentsAndEdits = attachmentsAndEdits.toMutableList().also { it[currentIndex] = AttachmentAndEdits( currentAttachment, @@ -409,28 +421,50 @@ class AttachmentsPreviewPresenter( displayImageEditError = displayImageEditError, sendActionState = sendActionState.value, textEditorState = textEditorState, - mediaOptimizationSelectorState = mediaOptimizationSelectorState, + mediaOptimizationSelectorState = mediaOptimizationSelectorStates[currentIndex], 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 suspend fun preProcessAttachments( attachments: List, - mediaOptimizationConfig: MediaOptimizationConfig, + mediaOptimizationConfigs: List, displayProgress: Boolean, sendActionState: MutableState, ) { sendActionState.value = SendActionState.Sending.Processing(displayProgress = displayProgress) val mediaUploadInfos = mutableListOf() - for (attachment in attachments) { + attachments.forEachIndexed { index, attachment -> when (attachment) { is Attachment.Media -> { mediaSender.preProcessMedia( uri = attachment.localMedia.uri, mimeType = attachment.localMedia.info.mimeType, - mediaOptimizationConfig = mediaOptimizationConfig, + mediaOptimizationConfig = mediaOptimizationConfigs[index], ).fold( onSuccess = { mediaUploadInfo -> Timber.d("Media ${mediaUploadInfo.file.path.orEmpty().hash()} finished processing") 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 f786e9526bf..a7d4e14436e 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 @@ -146,6 +146,7 @@ fun aMediaOptimisationSelectorState( displayMediaSelectorViews: Boolean = true, displayVideoPresetSelectorDialog: Boolean = false, ) = MediaOptimizationSelectorState( + index = 0, maxUploadSize = AsyncData.Success(maxUploadSize), videoSizeEstimations = videoSizeEstimations, isImageOptimizationEnabled = isImageOptimizationEnabled, 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 1b36c469fef..4a14c11f4be 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 @@ -346,7 +346,7 @@ private fun AttachmentPreviewContent( } } } - val mediaInfo = (state.attachments.first() as? Attachment.Media)?.localMedia?.info + val mediaInfo = (state.attachments[state.currentIndex] as? Attachment.Media)?.localMedia?.info if (mediaInfo?.isImageAttachment() == true) { ImageOptimizationSelector(state.mediaOptimizationSelectorState) } else if (mediaInfo?.mimeType?.isMimeTypeVideo() == true) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt index abc0264b2fc..c194bda507b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt @@ -36,6 +36,7 @@ import kotlin.math.roundToLong @AssistedInject class DefaultMediaOptimizationSelectorPresenter( + @Assisted private val index: Int, @Assisted private val localMedia: LocalMedia, @Assisted private val sendAsFile: Boolean, private val maxUploadSizeProvider: MaxUploadSizeProvider, @@ -48,6 +49,7 @@ class DefaultMediaOptimizationSelectorPresenter( @AssistedFactory interface Factory : MediaOptimizationSelectorPresenter.Factory { override fun create( + index: Int, localMedia: LocalMedia, sendAsFile: Boolean, ): DefaultMediaOptimizationSelectorPresenter @@ -183,6 +185,7 @@ class DefaultMediaOptimizationSelectorPresenter( } return MediaOptimizationSelectorState( + index = index, maxUploadSize = maxUploadSize, videoSizeEstimations = videoSizeEstimations, isImageOptimizationEnabled = selectedImageOptimization.dataOrNull(), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/MediaOptimizationSelectorPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/MediaOptimizationSelectorPresenter.kt index f1e17ef0a68..6c97557c37d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/MediaOptimizationSelectorPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/MediaOptimizationSelectorPresenter.kt @@ -14,6 +14,7 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMedia fun interface MediaOptimizationSelectorPresenter : Presenter { interface Factory { fun create( + index: Int, localMedia: LocalMedia, sendAsFile: Boolean, ): MediaOptimizationSelectorPresenter diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/MediaOptimizationSelectorState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/MediaOptimizationSelectorState.kt index 29e51d39af5..bc8bf90d557 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/MediaOptimizationSelectorState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/MediaOptimizationSelectorState.kt @@ -13,6 +13,8 @@ import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import kotlinx.collections.immutable.ImmutableList data class MediaOptimizationSelectorState( + // Adding an index helps to fix a Compose issue where the state of the wrong item is updated + val index: Int, val maxUploadSize: AsyncData, val videoSizeEstimations: AsyncData>, val isImageOptimizationEnabled: Boolean?, 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 485c76ead35..8370ec516f4 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,6 +23,7 @@ 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 @@ -93,6 +94,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { @Test fun `present - initial state`() = runTest { createAttachmentsPreviewPresenter().test { + skipItems(1) val initialState = awaitItem() assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) } @@ -117,6 +119,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { onDoneListener = { onDoneListener() }, ) presenter.test { + skipItems(1) val initialState = awaitItem() assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false)) @@ -151,6 +154,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { onDoneListener = { onDoneListener() }, ) presenter.test { + skipItems(1) val initialState = awaitItem() assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) // Pre-processing finishes @@ -187,6 +191,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { onDoneListener = { onDoneListener() }, ) presenter.test { + skipItems(1) val initialState = awaitItem() assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false)) @@ -216,6 +221,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { onDoneListener = { onDoneListener() }, ) presenter.test { + skipItems(1) val initialState = awaitItem() assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) initialState.eventSink(AttachmentsPreviewEvent.SendAttachment) @@ -239,6 +245,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { onDoneListener = { onDoneListener() }, ) presenter.test { + skipItems(1) val initialState = awaitItem() assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) // Pre-processing finishes @@ -261,6 +268,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { onDoneListener = { onDoneListener() }, ) presenter.test { + skipItems(1) val initialState = awaitItem() assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) initialState.eventSink(AttachmentsPreviewEvent.CancelAndDismiss) @@ -292,6 +300,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { onDoneListener = { onDoneListener() }, ) presenter.test { + skipItems(1) val initialState = awaitItem() assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) initialState.textEditorState.setMarkdown(A_CAPTION) @@ -333,6 +342,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { onDoneListener = { onDoneListener() }, ) presenter.test { + skipItems(1) val initialState = awaitItem() assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) initialState.textEditorState.setMarkdown(A_CAPTION) @@ -374,6 +384,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { onDoneListener = { onDoneListener() }, ) presenter.test { + skipItems(1) val initialState = awaitItem() assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) initialState.textEditorState.setMarkdown(A_CAPTION) @@ -408,6 +419,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { ) val presenter = createAttachmentsPreviewPresenter(room = room, onDoneListener = onDoneListenerResult) presenter.test { + skipItems(1) val initialState = awaitItem() assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) initialState.eventSink(AttachmentsPreviewEvent.SendAttachment) @@ -437,6 +449,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { onDoneListener = onDoneListenerResult, ) presenter.test { + skipItems(1) val initialState = awaitItem() assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) initialState.eventSink(AttachmentsPreviewEvent.SendAttachment) @@ -469,6 +482,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { onDoneListener = onDoneListenerResult, mediaOptimizationSelectorPresenterFactory = FakeMediaOptimizationSelectorPresenterFactory { MediaOptimizationSelectorState( + index = 0, // Set a max upload size smaller than the file size maxUploadSize = AsyncData.Success(maxUploadSize), videoSizeEstimations = AsyncData.Uninitialized, @@ -508,6 +522,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { onDoneListener = onDoneListenerResult, mediaOptimizationSelectorPresenterFactory = FakeMediaOptimizationSelectorPresenterFactory { MediaOptimizationSelectorState( + index = 0, // Set a max upload size smaller than the file size maxUploadSize = AsyncData.Success(Long.MAX_VALUE), videoSizeEstimations = AsyncData.Success( @@ -565,6 +580,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { ) presenter.test { + skipItems(1) val initialState = awaitItem() initialState.eventSink(AttachmentsPreviewEvent.OpenImageEditor) val editorState = awaitItem() @@ -775,6 +791,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { attachments = listOf(Attachment.Media(localMedia)), ) presenter.test { + skipItems(1) val initialState = awaitItem() assertThat(initialState.canEditImage).isTrue() @@ -833,6 +850,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { displayMediaQualitySelectorViews = false, mediaOptimizationSelectorPresenterFactory = FakeMediaOptimizationSelectorPresenterFactory { MediaOptimizationSelectorState( + index = 0, maxUploadSize = AsyncData.Success(250_000_000L), videoSizeEstimations = AsyncData.Success( persistentListOf( @@ -933,6 +951,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { mediaOptimizationSelectorPresenterFactory: FakeMediaOptimizationSelectorPresenterFactory = FakeMediaOptimizationSelectorPresenterFactory( fakePresenter = { MediaOptimizationSelectorState( + index = 0, maxUploadSize = AsyncData.Uninitialized, videoSizeEstimations = AsyncData.Uninitialized, isImageOptimizationEnabled = null, @@ -957,6 +976,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { Result.failure(IllegalStateException("Check test values")) } }, + videoCompressionPresetSelector: VideoCompressionPresetSelector = VideoCompressionPresetSelector(), ): AttachmentsPreviewPresenter { return AttachmentsPreviewPresenter( attachments = attachments.toImmutableList(), @@ -977,6 +997,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { sessionCoroutineScope = this, dispatchers = testCoroutineDispatchers(), mediaOptimizationSelectorPresenterFactory = mediaOptimizationSelectorPresenterFactory, + videoCompressionPresetSelector = videoCompressionPresetSelector, timelineMode = timelineMode, inReplyToEventId = null, mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenterTest.kt index 99483bdac46..83c5c5f5756 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenterTest.kt @@ -274,6 +274,7 @@ class DefaultMediaOptimizationSelectorPresenterTest : RobolectricTest() { } private fun createDefaultMediaOptimizationSelectorPresenter( + index: Int = 0, localMedia: LocalMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()), maxUploadSizeProvider: MaxUploadSizeProvider = MaxUploadSizeProvider { Result.success(1_000L) }, featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SelectableMediaQuality.key to true)), @@ -283,6 +284,7 @@ class DefaultMediaOptimizationSelectorPresenterTest : RobolectricTest() { sendAsFile: Boolean = false, ): DefaultMediaOptimizationSelectorPresenter { return DefaultMediaOptimizationSelectorPresenter( + index = index, localMedia = localMedia, sendAsFile = sendAsFile, maxUploadSizeProvider = maxUploadSizeProvider, diff --git a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/attachments/video/FakeMediaOptimizationSelectorPresenterFactory.kt b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/attachments/video/FakeMediaOptimizationSelectorPresenterFactory.kt index fff3ede5d3c..af068a14c2d 100644 --- a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/attachments/video/FakeMediaOptimizationSelectorPresenterFactory.kt +++ b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/attachments/video/FakeMediaOptimizationSelectorPresenterFactory.kt @@ -16,6 +16,7 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMedia class FakeMediaOptimizationSelectorPresenterFactory( private val fakePresenter: MediaOptimizationSelectorPresenter = MediaOptimizationSelectorPresenter { MediaOptimizationSelectorState( + index = 0, maxUploadSize = AsyncData.Uninitialized, videoSizeEstimations = AsyncData.Uninitialized, isImageOptimizationEnabled = null, @@ -26,7 +27,7 @@ class FakeMediaOptimizationSelectorPresenterFactory( ) } ) : MediaOptimizationSelectorPresenter.Factory { - override fun create(localMedia: LocalMedia, sendAsFile: Boolean): MediaOptimizationSelectorPresenter { + override fun create(index: Int, localMedia: LocalMedia, sendAsFile: Boolean): MediaOptimizationSelectorPresenter { return fakePresenter } } From 4566712ad3942cfecfa72b2a1453d24e9433ecf9 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 3 Jul 2026 09:49:06 +0200 Subject: [PATCH 25/27] Take user choice into consideration when pre-processing the media. --- .../attachments/preview/AttachmentsPreviewPresenter.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 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 d60de004d90..8ebb580a908 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 @@ -445,8 +445,12 @@ class AttachmentsPreviewPresenter( videoCompressionPreset = videoCompressionPreset, ) } else { - // Otherwise, we just rely on the user preferences for media optimization - mediaOptimizationConfigProvider.get() + MediaOptimizationConfig( + compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled + ?: mediaOptimizationConfigProvider.get().compressImages, + videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset + ?: mediaOptimizationConfigProvider.get().videoCompressionPreset, + ) } } From 74f69b7eb37ba50a88d22562b28f0d5e34fbb148 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 3 Jul 2026 09:50:35 +0200 Subject: [PATCH 26/27] Optimize code --- .../mediaviewer/impl/datasource/TimelineMediaItemsFactory.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 86f77307807..1c96e0f44b4 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 @@ -71,8 +71,8 @@ private fun MatrixTimelineItem.Event.isUnchanged( previousItems: List, ): Boolean { val previousItem = previousItems - .filterIsInstance() - .find { it.uniqueId == uniqueId } + .find { (it as? MatrixTimelineItem.Event)?.uniqueId == uniqueId } + as? MatrixTimelineItem.Event return previousItem != null && previousItem.event.eventId == event.eventId && previousItem.event.timestamp == event.timestamp From 762823ebd7e443cd3e1becf18b24524050acd577 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 3 Jul 2026 09:52:12 +0200 Subject: [PATCH 27/27] `SampleMediaRenderer` can be internal. --- .../messages/impl/attachments/preview/SampleMediaRenderer.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/SampleMediaRenderer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/SampleMediaRenderer.kt index f0fd834ce66..df981a5d08c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/SampleMediaRenderer.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/SampleMediaRenderer.kt @@ -20,7 +20,7 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer * An implementation of [LocalMediaRenderer] that displays a sample background image. * To be used for Previews only. */ -class SampleMediaRenderer : LocalMediaRenderer { +internal class SampleMediaRenderer : LocalMediaRenderer { @Composable override fun Render(localMedia: LocalMedia) { Image(