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 36e94ec4562..67dceadf5bd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -46,6 +46,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent @@ -143,10 +144,11 @@ class MessagesFlowNode( val mediaInfo: MediaInfo, val mediaSource: MediaSource, val thumbnailSource: MediaSource?, + val galleryItems: List = emptyList(), ) : NavTarget @Parcelize - data class AttachmentPreview(val timelineMode: Timeline.Mode, val attachment: Attachment, val inReplyToEventId: EventId?) : NavTarget + data class AttachmentPreview(val timelineMode: Timeline.Mode, val attachments: ImmutableList, val inReplyToEventId: EventId?) : NavTarget @Parcelize data class LocationViewer(val mode: ShowLocationMode) : NavTarget @@ -227,17 +229,18 @@ class MessagesFlowNode( callback.navigateToRoomDetails() } - override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean { + override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, galleryItemIndex: Int?): Boolean { return processEventClick( timelineMode = timelineMode, event = event, + galleryItemIndex = galleryItemIndex, ) } override fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) { backstack.push( NavTarget.AttachmentPreview( - attachment = attachments.first(), + attachments = attachments, timelineMode = Timeline.Mode.Live, inReplyToEventId = inReplyToEventId, ) @@ -317,6 +320,7 @@ class MessagesFlowNode( mediaSource = navTarget.mediaSource, thumbnailSource = navTarget.thumbnailSource, canShowInfo = true, + galleryItems = navTarget.galleryItems, ) val callback = object : MediaViewerEntryPoint.Callback { override fun onDone() { @@ -341,7 +345,7 @@ class MessagesFlowNode( } is NavTarget.AttachmentPreview -> { val inputs = AttachmentsPreviewNode.Inputs( - attachment = navTarget.attachment, + attachments = navTarget.attachments, timelineMode = navTarget.timelineMode, inReplyToEventId = navTarget.inReplyToEventId, ) @@ -456,17 +460,18 @@ class MessagesFlowNode( focusedEventId = navTarget.focusedEventId, ) val callback = object : ThreadedMessagesNode.Callback { - override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean { + override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, galleryItemIndex: Int?): Boolean { return processEventClick( timelineMode = timelineMode, event = event, + 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, ) @@ -547,6 +552,7 @@ class MessagesFlowNode( private fun processEventClick( timelineMode: Timeline.Mode, event: TimelineItem.Event, + galleryItemIndex: Int? = null, ): Boolean { val navTarget = when (event.content) { is TimelineItemImageContent -> { @@ -599,6 +605,58 @@ class MessagesFlowNode( } NavTarget.LocationViewer(mode = mode).takeIf { locationService.isServiceAvailable() } } + is TimelineItemGalleryContent -> { + val item = if (galleryItemIndex != null) { + event.content.items.getOrNull(galleryItemIndex) + } else { + event.content.items.firstOrNull() + } ?: return false + val mediaInfo = MediaInfo( + filename = item.filename, + fileSize = null, + caption = event.content.caption, + mimeType = item.mimeType, + formattedFileSize = "", + fileExtension = item.filename.substringAfterLast('.', ""), + senderId = event.senderId, + senderName = event.safeSenderName, + senderAvatar = event.senderAvatar.url, + dateSent = dateFormatter.format( + event.sentTimeMillis, + mode = DateFormatterMode.Day, + ), + dateSentFull = dateFormatter.format( + timestamp = event.sentTimeMillis, + mode = DateFormatterMode.Full, + ), + waveform = null, + duration = null, + ) + val galleryItems = event.content.items.map { galleryItem -> + io.element.android.libraries.mediaviewer.api.GalleryItemData( + filename = galleryItem.filename, + mimeType = galleryItem.mimeType, + mediaSource = galleryItem.mediaSource, + thumbnailSource = galleryItem.thumbnailSource, + isVideo = galleryItem.isVideo, + isAudio = galleryItem.isAudio, + isFile = galleryItem.isFile, + ) + } + val mode = if (event.content.items.any { it.isVideo || (!it.isAudio && !it.isFile) }) { + MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(timelineMode) + } else { + MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode) + } + NavTarget.MediaViewer( + mode = mode, + eventId = event.eventId, + mediaInfo = mediaInfo, + mediaSource = item.mediaSource, + thumbnailSource = item.thumbnailSource, + galleryItems = galleryItems, + ) + } else -> null } return when (navTarget) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index a2cf4a3da03..432ee094f68 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 @@ -115,7 +115,7 @@ class MessagesNode( ) interface Callback : Plugin { - fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean + fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, galleryItemIndex: Int? = null): Boolean fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) fun navigateToRoomMemberDetails(userId: UserId) fun handlePermalinkClick(data: PermalinkData) @@ -278,6 +278,18 @@ class MessagesNode( } } }, + onGalleryItemClick = { isLive, event, index -> + if (isLive) { + callback.handleEventClick(timelineController.mainTimelineMode(), event, index) + } else { + val detachedTimelineMode = timelineController.detachedTimelineMode() + if (detachedTimelineMode != null) { + callback.handleEventClick(detachedTimelineMode, event, index) + } else { + false + } + } + }, onUserDataClick = callback::navigateToRoomMemberDetails, onLinkClick = { url, customTab -> onLinkClick( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index bf20c8dc6b5..57aa570d577 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 @@ -134,6 +134,7 @@ fun MessagesView( onBackClick: () -> Unit, onRoomDetailsClick: () -> Unit, onEventContentClick: (isLive: Boolean, event: TimelineItem.Event) -> Boolean, + onGalleryItemClick: ((isLive: Boolean, event: TimelineItem.Event, index: Int) -> Boolean)? = null, onUserDataClick: (UserId) -> Unit, onLinkClick: (String, Boolean) -> Unit, onSendLocationClick: () -> Unit, @@ -256,6 +257,15 @@ fun MessagesView( MessagesViewContent( state = state, onContentClick = ::onContentClick, + onGalleryItemClick = { evt, idx -> + Timber.v("onGalleryItemClick= ${evt.id} index=$idx") + val isLive = state.timelineState.isLive + val handledByGallery = onGalleryItemClick?.invoke(isLive, evt, idx) + val hideKeyboard = handledByGallery ?: onEventContentClick(isLive, evt) + if (hideKeyboard) { + localView.hideKeyboard() + } + }, onMessageLongClick = ::onMessageLongClick, onUserDataClick = { hidingKeyboard { @@ -451,6 +461,7 @@ private fun ReinviteDialog(state: MessagesState) { private fun MessagesViewContent( state: MessagesState, onContentClick: (TimelineItem.Event) -> Unit, + onGalleryItemClick: ((TimelineItem.Event, Int) -> Unit)? = null, onUserDataClick: (MatrixUser) -> Unit, onLinkClick: (Link, Boolean) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit, @@ -510,6 +521,7 @@ private fun MessagesViewContent( onUserDataClick = onUserDataClick, onLinkClick = { link -> onLinkClick(link, false) }, onContentClick = onContentClick, + onGalleryItemClick = onGalleryItemClick, onMessageLongClick = onMessageLongClick, onSwipeToReply = onSwipeToReply, onReactionClick = onReactionClick, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt index ef446e36cfc..321aec03bf9 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 @@ -64,8 +64,10 @@ import io.element.android.features.messages.impl.timeline.a11y.a11yReactionActio 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.TimelineItemAudioContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent 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 @@ -298,6 +300,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/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 8e92e53f6a8..0b58f34a571 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 @@ -44,6 +44,7 @@ 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.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -53,7 +54,7 @@ import timber.log.Timber @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 +69,7 @@ class AttachmentsPreviewPresenter( @AssistedFactory interface Factory { fun create( - attachment: Attachment, + attachments: ImmutableList, timelineMode: Timeline.Mode, onDoneListener: OnDoneListener, inReplyToEventId: EventId?, @@ -76,6 +77,7 @@ class AttachmentsPreviewPresenter( } private val mediaSender = mediaSenderFactory.create(timelineMode) + private val isGallery = attachments.size > 1 @Composable override fun present(): AttachmentsPreviewState { @@ -94,9 +96,9 @@ class AttachmentsPreviewPresenter( var preprocessMediaJob by remember { mutableStateOf(null) } - val mediaAttachment = attachment as Attachment.Media + val firstMediaAttachment = attachments.first() as Attachment.Media val mediaOptimizationSelectorPresenter = remember { - mediaOptimizationSelectorPresenterFactory.create(mediaAttachment.localMedia) + mediaOptimizationSelectorPresenterFactory.create(firstMediaAttachment.localMedia) } val mediaOptimizationSelectorState by rememberUpdatedState(mediaOptimizationSelectorPresenter.present()) @@ -109,23 +111,25 @@ class AttachmentsPreviewPresenter( // to prepare it for sending. This is done to avoid blocking the UI thread when the // user clicks on the send button. if (mediaOptimizationSelectorState.displayMediaSelectorViews == false) { - preprocessMediaJob = preProcessAttachment( - attachment = attachment, - mediaOptimizationConfig = mediaOptimizationConfigProvider.get(), - displayProgress = false, - sendActionState = sendActionState, - ) + preprocessMediaJob = coroutineScope.launch(dispatchers.io) { + preProcessAttachments( + attachments = attachments, + mediaOptimizationConfig = mediaOptimizationConfigProvider.get(), + displayProgress = false, + sendActionState = sendActionState, + ) + } } } 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.mimeType.isMimeTypeImage() - val isVideoFile = mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo() + val isImageFile = firstMediaAttachment.localMedia.info.mimeType.isMimeTypeImage() + val isVideoFile = firstMediaAttachment.localMedia.info.mimeType.isMimeTypeVideo() if (maxUploadSize != null && !(isImageFile || isVideoFile)) { // If file size is not known, we're permissive and allow sending. The SDK will cancel the upload if needed. - val fileSize = mediaAttachment.localMedia.info.fileSize ?: 0L + val fileSize = firstMediaAttachment.localMedia.info.fileSize ?: 0L if (maxUploadSize < fileSize) { displayFileTooLargeError = true } @@ -151,12 +155,14 @@ class AttachmentsPreviewPresenter( compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled == true, videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: VideoCompressionPreset.STANDARD, ) - preprocessMediaJob = preProcessAttachment( - attachment = attachment, - mediaOptimizationConfig = config, - displayProgress = true, - sendActionState = sendActionState, - ) + preprocessMediaJob = coroutineScope.launch(dispatchers.io) { + preProcessAttachments( + attachments = attachments, + mediaOptimizationConfig = config, + displayProgress = true, + sendActionState = sendActionState, + ) + } } // If the processing was hidden before, make it visible now @@ -164,25 +170,19 @@ class AttachmentsPreviewPresenter( sendActionState.value = SendActionState.Sending.Processing(displayProgress = true) } - // Wait until the media is ready to be uploaded - val mediaUploadInfo = observableSendState.firstInstanceOf().mediaInfo + // Wait until all media is ready to be uploaded + val allMediaUploadInfos = observableSendState.firstInstanceOf().mediaInfos // Pre-processing is done, send the attachment val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder) .takeIf { it.isNotEmpty() } - // If we're supposed to send the media as a background job, we can dismiss this screen already - if (coroutineContext.isActive) { - onDoneListener() - } - // Send the media using the session coroutine scope so it doesn't matter if this screen or the chat one are closed sessionCoroutineScope.launch(dispatchers.io) { - sendPreProcessedMedia( - mediaUploadInfo = mediaUploadInfo, + sendGalleryPreProcessed( + mediaUploadInfos = allMediaUploadInfos, caption = caption, sendActionState = sendActionState, - dismissAfterSend = false, inReplyToEventId = inReplyToEventId, ) @@ -202,7 +202,7 @@ class AttachmentsPreviewPresenter( // Dismiss the screen dismiss( - attachment, + attachments, sendActionState, ) } @@ -215,7 +215,7 @@ class AttachmentsPreviewPresenter( val mediaUploadInfo = sendActionState.value.mediaUploadInfo() sendActionState.value = if (mediaUploadInfo != null) { - SendActionState.Sending.ReadyToUpload(mediaUploadInfo) + SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo)) } else { SendActionState.Idle } @@ -224,7 +224,7 @@ class AttachmentsPreviewPresenter( } return AttachmentsPreviewState( - attachment = attachment, + attachments = attachments, sendActionState = sendActionState.value, textEditorState = textEditorState, mediaOptimizationSelectorState = mediaOptimizationSelectorState, @@ -233,64 +233,57 @@ class AttachmentsPreviewPresenter( ) } - private fun CoroutineScope.preProcessAttachment( - attachment: Attachment, - mediaOptimizationConfig: MediaOptimizationConfig, - displayProgress: Boolean, - sendActionState: MutableState, - ) = launch(dispatchers.io) { - when (attachment) { - is Attachment.Media -> { - preProcessMedia( - mediaAttachment = attachment, - mediaOptimizationConfig = mediaOptimizationConfig, - displayProgress = displayProgress, - sendActionState = sendActionState, - ) - } - } - } - - private suspend fun preProcessMedia( - mediaAttachment: Attachment.Media, + private suspend fun preProcessAttachments( + attachments: List, mediaOptimizationConfig: MediaOptimizationConfig, displayProgress: Boolean, sendActionState: MutableState, ) { sendActionState.value = SendActionState.Sending.Processing(displayProgress = displayProgress) - mediaSender.preProcessMedia( - uri = mediaAttachment.localMedia.uri, - mimeType = mediaAttachment.localMedia.info.mimeType, - mediaOptimizationConfig = mediaOptimizationConfig, - ).fold( - onSuccess = { mediaUploadInfo -> - Timber.d("Media ${mediaUploadInfo.file.path.orEmpty().hash()} finished processing, it's now ready to upload") - sendActionState.value = SendActionState.Sending.ReadyToUpload(mediaUploadInfo) - }, - onFailure = { - Timber.e(it, "Failed to pre-process media") - if (it is CancellationException) { - throw it - } else { - sendActionState.value = SendActionState.Failure(it, null) + val mediaUploadInfos = mutableListOf() + for (attachment in attachments) { + when (attachment) { + is Attachment.Media -> { + mediaSender.preProcessMedia( + uri = attachment.localMedia.uri, + mimeType = attachment.localMedia.info.mimeType, + mediaOptimizationConfig = mediaOptimizationConfig, + ).fold( + onSuccess = { mediaUploadInfo -> + Timber.d("Media ${mediaUploadInfo.file.path.orEmpty().hash()} finished processing") + mediaUploadInfos.add(mediaUploadInfo) + }, + onFailure = { + Timber.e(it, "Failed to pre-process media") + if (it is CancellationException) { + throw it + } else { + sendActionState.value = SendActionState.Failure(it, null) + return + } + } + ) } } - ) + } + sendActionState.value = SendActionState.Sending.ReadyToUpload(mediaUploadInfos) } private fun dismiss( - attachment: Attachment, + attachments: List, sendActionState: MutableState, ) { - // Delete the temporary file - when (attachment) { - is Attachment.Media -> { - temporaryUriDeleter.delete(attachment.localMedia.uri) - sendActionState.value.mediaUploadInfo()?.let { data -> - cleanUp(data) + // Delete temporary files + for (attachment in attachments) { + when (attachment) { + is Attachment.Media -> { + temporaryUriDeleter.delete(attachment.localMedia.uri) } } } + // Clean up processed media + val uploadInfos = (sendActionState.value as? SendActionState.Sending.ReadyToUpload)?.mediaInfos + uploadInfos?.forEach { cleanUp(it) } // Reset the sendActionState to ensure that dialog is closed before the screen sendActionState.value = SendActionState.Done onDoneListener() @@ -304,36 +297,43 @@ class AttachmentsPreviewPresenter( } } - 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) { + // Single item - use the regular send path + sendActionState.value = SendActionState.Sending.Uploading(mediaUploadInfos.first()) + mediaSender.sendPreProcessedMedia( + mediaUploadInfo = mediaUploadInfos.first(), + caption = caption, + formattedCaption = null, + inReplyToEventId = inReplyToEventId, + ).getOrThrow() + } else { + // Multiple items - use gallery send + mediaSender.sendGallery( + mediaUploadInfos = mediaUploadInfos, + caption = caption, + formattedCaption = null, + inReplyToEventId = inReplyToEventId, + ).getOrThrow() + } }.fold( onSuccess = { - cleanUp(mediaUploadInfo) + mediaUploadInfos.forEach { cleanUp(it) } // Reset the sendActionState to ensure that dialog is closed before the screen sendActionState.value = SendActionState.Done - - if (dismissAfterSend) { - onDoneListener() - } + onDoneListener() }, onFailure = { error -> Timber.e(error, "Failed to send attachment") if (error is CancellationException) { throw error } else { - sendActionState.value = SendActionState.Failure(error, mediaUploadInfo) + sendActionState.value = SendActionState.Failure(error, mediaUploadInfos.firstOrNull()) } } ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt index 97ca230d775..864119a4be9 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 @@ -13,15 +13,18 @@ import io.element.android.features.messages.impl.attachments.Attachment 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 sendActionState: SendActionState, val textEditorState: TextEditorState, val mediaOptimizationSelectorState: MediaOptimizationSelectorState, val displayFileTooLargeError: Boolean, val eventSink: (AttachmentsPreviewEvent) -> Unit, -) +) { + val isGallery: Boolean get() = attachments.size > 1 +} @Immutable sealed interface SendActionState { @@ -30,7 +33,7 @@ sealed interface SendActionState { @Immutable sealed interface Sending : SendActionState { data class Processing(val displayProgress: Boolean) : Sending - data class ReadyToUpload(val mediaInfo: MediaUploadInfo) : Sending + data class ReadyToUpload(val mediaInfos: List) : Sending data class Uploading(val mediaUploadInfo: MediaUploadInfo) : Sending } @@ -38,7 +41,7 @@ sealed interface SendActionState { data object Done : SendActionState fun mediaUploadInfo(): MediaUploadInfo? = when (this) { - is Sending.ReadyToUpload -> mediaInfo + is Sending.ReadyToUpload -> mediaInfos.firstOrNull() is Sending.Uploading -> mediaUploadInfo is Failure -> mediaUploadInfo else -> null diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt index 70d7ab006ef..abdb5e336bd 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 @@ -39,7 +39,7 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider { - localMediaRenderer.Render(attachment.localMedia) + localMediaRenderer.Render(firstAttachment.localMedia) } } + if (state.isGallery) { + GalleryBadge(count = state.attachments.size) + } } - val mimeType = (state.attachment as? Attachment.Media)?.localMedia?.info?.mimeType + val mimeType = (state.attachments.first() as? Attachment.Media)?.localMedia?.info?.mimeType if (mimeType?.isMimeTypeImage() == true) { ImageOptimizationSelector(state.mediaOptimizationSelectorState) } else if (mimeType?.isMimeTypeVideo() == true) { @@ -442,3 +449,26 @@ fun VideoCompressionPreset.subtitle(): String { } ) } + +@Composable +private fun GalleryBadge(count: Int, modifier: Modifier = Modifier) { + val contentDescription = pluralStringResource(R.plurals.screen_attachments_preview_gallery_badge_a11y, count, count) + Box( + modifier = modifier + .padding(12.dp) + .background( + color = ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.8f), + shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp), + ) + .padding(horizontal = 8.dp, vertical = 4.dp) + .semantics { + this.contentDescription = contentDescription + }, + ) { + Text( + text = "$count", + style = ElementTheme.typography.fontBodySmMedium, + color = ElementTheme.colors.textPrimary, + ) + } +} 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 90b91691a92..d77ef0a8b69 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 @@ -179,8 +179,11 @@ class MessageComposerPresenter( val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType -> handlePickedMedia(uri, mimeType) } - val filesPicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri, mimeType -> - handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream) + val galleryMultiMediaPicker = mediaPickerProvider.registerGalleryMultiPicker { uris -> + handlePickedMediaList(uris) + } + val filesPicker = mediaPickerProvider.registerFileMultiPicker(AnyMimeTypes) { uris -> + handlePickedMediaList(uris) } val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri -> handlePickedMedia(uri, MimeTypes.Jpeg) @@ -289,7 +292,7 @@ class MessageComposerPresenter( MessageComposerEvent.DismissAttachmentMenu -> showAttachmentSourcePicker = false MessageComposerEvent.PickAttachmentSource.FromGallery -> localCoroutineScope.launch { showAttachmentSourcePicker = false - galleryMediaPicker.launch() + galleryMultiMediaPicker.launch() } MessageComposerEvent.PickAttachmentSource.FromFiles -> localCoroutineScope.launch { showAttachmentSourcePicker = false @@ -622,6 +625,30 @@ class MessageComposerPresenter( messageComposerContext.composerMode = MessageComposerMode.Normal } + private fun handlePickedMediaList( + uris: List, + ) { + if (uris.isEmpty()) return + if (uris.size == 1) { + handlePickedMedia(uris.first()) + return + } + val attachments = uris.map { uri -> + val localMedia = localMediaFactory.createFromUri( + uri = uri, + mimeType = null, + name = null, + formattedFileSize = null, + ) + Attachment.Media(localMedia) + }.toImmutableList() + val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId + navigator.navigateToPreviewAttachments(attachments, inReplyToEventId) + + // Reset composer since the attachment will be sent in a separate flow + messageComposerContext.composerMode = MessageComposerMode.Normal + } + private suspend fun sendMedia( uri: Uri, mimeType: String, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt index 09492378629..4534ff58f9f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt @@ -124,7 +124,7 @@ class ThreadedMessagesNode( } interface Callback : Plugin { - fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean + fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, galleryItemIndex: Int? = null): Boolean fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) fun navigateToRoomMemberDetails(userId: UserId) fun handlePermalinkClick(data: PermalinkData) @@ -282,6 +282,20 @@ class ThreadedMessagesNode( } } == true }, + onGalleryItemClick = { isLive, event, index -> + timelineController?.let { controller -> + if (isLive) { + callback.handleEventClick(controller.mainTimelineMode(), event, index) + } else { + val detachedTimelineMode = controller.detachedTimelineMode() + if (detachedTimelineMode != null) { + callback.handleEventClick(detachedTimelineMode, event, index) + } else { + false + } + } + } == true + }, onUserDataClick = callback::navigateToRoomMemberDetails, onLinkClick = { url, customTab -> onLinkClick( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index 0c5bb288903..8605c20da38 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -97,6 +97,7 @@ fun TimelineView( onUserDataClick: (MatrixUser) -> Unit, onLinkClick: (Link) -> Unit, onContentClick: (TimelineItem.Event) -> Unit, + onGalleryItemClick: ((TimelineItem.Event, Int) -> Unit)? = null, onMessageLongClick: (TimelineItem.Event) -> Unit, onSwipeToReply: (TimelineItem.Event) -> Unit, onReactionClick: (emoji: String, TimelineItem.Event) -> Unit, @@ -180,6 +181,7 @@ fun TimelineView( onLinkClick = onLinkClick, onLinkLongClick = ::onLinkLongClick, onContentClick = onContentClick, + onGalleryItemClick = onGalleryItemClick, onLongClick = onMessageLongClick, inReplyToClick = ::inReplyToClick, onReactionClick = onReactionClick, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index 976fa3c17e4..d3034162713 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -70,12 +70,16 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAttachmentsContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemGalleryContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation @@ -155,6 +159,7 @@ fun TimelineItemEventRow( onMoreReactionsClick: (eventId: TimelineItem.Event) -> Unit, onReadReceiptClick: (event: TimelineItem.Event) -> Unit, onSwipeToReply: () -> Unit, + onGalleryItemClick: ((Int) -> Unit)? = null, eventSink: (TimelineEvent.TimelineItemEvent) -> Unit, modifier: Modifier = Modifier, eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = { contentModifier, onContentLayoutChange -> @@ -165,13 +170,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, ) }, ) { @@ -777,6 +783,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 -> TimestampPosition.Below + is TimelineItemAttachmentsContent -> TimestampPosition.Below is TimelineItemStickerContent -> TimestampPosition.Overlay is TimelineItemLocationContent -> { val content = content.ensureActiveLiveLocation() @@ -791,6 +799,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 @@ -834,6 +844,249 @@ internal fun TimelineItemEventRowPreview() = ElementPreview { } } +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithGalleryPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = aTimelineItemGalleryContent( + caption = "My vacation photos", + items = listOf( + io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(), + io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(), + io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(), + io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(), + ), + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithGalleryTwoItemsPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = aTimelineItemGalleryContent( + items = listOf( + io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(), + io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(), + ), + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithGalleryThreeItemsPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = aTimelineItemGalleryContent( + caption = "Three photos", + items = listOf( + io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(), + io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(), + io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(), + ), + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithGalleryManyItemsPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = aTimelineItemGalleryContent( + caption = "Many photos", + items = (1..8).map { io.element.android.features.messages.impl.timeline.model.event.aGalleryItem() }, + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithGalleryVideoItemsPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = aTimelineItemGalleryContent( + caption = "Videos", + items = listOf( + io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(isVideo = true, duration = kotlin.time.Duration.parse("PT1M30S")), + io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(isVideo = true, duration = kotlin.time.Duration.parse("PT45S")), + io.element.android.features.messages.impl.timeline.model.event.aGalleryItem(), + ), + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithAttachmentsPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = aTimelineItemAttachmentsContent( + caption = "Documents", + attachments = listOf( + io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem( + filename = "document.pdf", + fileExtension = "pdf", + ), + io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem( + filename = "presentation.pdf", + fileExtension = "pdf", + ), + io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem( + filename = "spreadsheet.xlsx", + fileExtension = "xlsx", + ), + ), + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithAttachmentsImagesPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = aTimelineItemAttachmentsContent( + caption = "Photos", + attachments = listOf( + io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem( + filename = "photo1.jpg", + fileExtension = "jpg", + hasThumbnail = true, + ), + io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem( + filename = "photo2.jpg", + fileExtension = "jpg", + hasThumbnail = true, + ), + io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem( + filename = "photo3.jpg", + fileExtension = "jpg", + hasThumbnail = true, + ), + ), + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithAttachmentsVideosPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = aTimelineItemAttachmentsContent( + caption = "Videos", + attachments = listOf( + io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem( + filename = "video1.mp4", + fileExtension = "mp4", + hasThumbnail = true, + fileSize = 150_000_000L, + formattedFileSize = "150MB", + ), + io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem( + filename = "video2.mov", + fileExtension = "mov", + hasThumbnail = true, + fileSize = 85_000_000L, + formattedFileSize = "85MB", + ), + ), + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithAttachmentsAudioPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = aTimelineItemAttachmentsContent( + caption = "Audio", + attachments = listOf( + io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem( + filename = "recording.mp3", + fileExtension = "mp3", + fileSize = 4_500_000L, + formattedFileSize = "4.5MB", + ), + io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem( + filename = "voice_message.m4a", + fileExtension = "m4a", + fileSize = 1_200_000L, + formattedFileSize = "1.2MB", + ), + ), + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + } +} + @PreviewsDayNight @Composable internal fun TimelineItemEventRowWithThreadSummaryPreview() = ElementPreview { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index 469afe494e8..b8d3353e9d7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -65,6 +65,7 @@ internal fun TimelineItemRow( onLinkClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit, onContentClick: (TimelineItem.Event) -> Unit, + onGalleryItemClick: ((TimelineItem.Event, Int) -> Unit)? = null, onLongClick: (TimelineItem.Event) -> Unit, inReplyToClick: (EventId) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit, @@ -82,12 +83,13 @@ internal fun TimelineItemRow( hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId), onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, onContentClick = { onContentClick(event) }, + onGalleryItemClick = { index -> onGalleryItemClick?.invoke(event, index) }, onLongClick = { onLongClick(event) }, onLinkClick = onLinkClick, onLinkLongClick = onLinkLongClick, eventSink = eventSink, modifier = contentModifier, - onContentLayoutChange = onContentLayoutChange + onContentLayoutChange = onContentLayoutChange, ) }, ) { @@ -181,6 +183,7 @@ internal fun TimelineItemRow( onMoreReactionsClick = onMoreReactionsClick, onReadReceiptClick = onReadReceiptClick, onSwipeToReply = { onSwipeToReply(timelineItem) }, + onGalleryItemClick = { index -> onGalleryItemClick?.invoke(timelineItem, index) }, eventSink = eventSink, eventContentView = { contentModifier, onContentLayoutChange -> eventContentView(timelineItem, contentModifier, onContentLayoutChange) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt new file mode 100644 index 00000000000..8d79ae78bff --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt @@ -0,0 +1,305 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.event + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData +import io.element.android.features.messages.impl.timeline.model.event.AttachmentItem +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun TimelineItemAttachmentsListView( + content: TimelineItemAttachmentsContent, + onContentClick: ((Int) -> Unit)?, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, + modifier: Modifier = Modifier, +) { + if (content.attachments.isEmpty()) return + + Column(modifier = modifier) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + content.attachments.forEachIndexed { index, attachment -> + Column { + if (index > 0) { + HorizontalDivider( + modifier = Modifier, + thickness = 1.dp, + ) + } + AttachmentListItem( + attachment = attachment, + onClick = onContentClick?.let { { it(index) } }, + onContentLayoutChange = onContentLayoutChange, + ) + } + } + } + + if (content.showCaption) { + HorizontalDivider( + modifier = Modifier, + thickness = 1.dp, + ) + val caption = if (LocalInspectionMode.current) { + android.text.SpannedString(content.caption) + } else { + (content.formattedCaption ?: android.text.SpannedString(content.caption)).let { + if (it is String) it else android.text.SpannedString(content.caption) + } + } + CompositionLocalProvider( + LocalContentColor provides ElementTheme.colors.textPrimary, + LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular + ) { + Text( + modifier = Modifier + .padding(top = 8.dp, start = 4.dp, end = 4.dp) + .widthIn(min = 120.dp), + text = caption.toString(), + style = ElementTheme.typography.fontBodyLgRegular, + maxLines = 5, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun AttachmentListItem( + attachment: AttachmentItem, + onClick: (() -> Unit)?, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, + modifier: Modifier = Modifier, +) { + val iconSize = 36.dp + val thumbnailSize = 36L + val spacing = 8.dp + val hasCaption = false + + Row( + modifier = modifier + .padding(vertical = 6.dp) + .then( + if (onClick != null) { + Modifier.clickable(onClick = onClick) + } else { + Modifier + } + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing), + ) { + Box( + modifier = Modifier + .size(iconSize) + .clip(RoundedCornerShape(4.dp)) + .background(ElementTheme.colors.bgCanvasDefault), + contentAlignment = Alignment.Center, + ) { + if (attachment.thumbnailSource != null) { + val isVideo = attachment.mimeType.isMimeTypeVideo() + + AsyncImage( + model = MediaRequestData( + source = attachment.thumbnailSource, + kind = MediaRequestData.Kind.Thumbnail(thumbnailSize), + ), + contentDescription = attachment.filename, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(iconSize) + .clip(RoundedCornerShape(4.dp)), + ) + + if (isVideo) { + Box( + modifier = Modifier + .size(iconSize) + .background( + color = androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.3f), + shape = RoundedCornerShape(4.dp) + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = CompoundIcons.VideoCallSolid(), + contentDescription = stringResource(CommonStrings.common_video), + tint = androidx.compose.ui.graphics.Color.White, + modifier = Modifier.size(20.dp), + ) + } + } + } else { + val isImage = attachment.mimeType.isMimeTypeImage() + val isVideo = attachment.mimeType.isMimeTypeVideo() + val isAudio = attachment.mimeType.isMimeTypeAudio() + + val icon = when { + isImage -> CompoundIcons.Image() + isVideo -> CompoundIcons.VideoCall() + isAudio -> CompoundIcons.Audio() + else -> CompoundIcons.Attachment() + } + + Icon( + imageVector = icon, + contentDescription = null, + tint = ElementTheme.colors.iconPrimary, + modifier = Modifier.size(24.dp), + ) + } + } + + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = attachment.filename, + color = ElementTheme.colors.textPrimary, + style = ElementTheme.typography.fontBodyLgRegular, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "${attachment.fileExtension} • ${attachment.formattedFileSize}", + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodySmRegular, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + onTextLayout = if (hasCaption) { + {} + } else { + ContentAvoidingLayout.measureLastTextLine( + onContentLayoutChange = onContentLayoutChange, + extraWidth = iconSize + spacing + ) + }, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemAttachmentsListViewPreview() = ElementPreview { + TimelineItemAttachmentsListView( + content = TimelineItemAttachmentsContent( + body = "Files", + caption = null, + formattedCaption = null, + isEdited = false, + attachments = listOf( + AttachmentItem( + filename = "document.pdf", + mimeType = "application/pdf", + mediaSource = io.element.android.libraries.matrix.api.media.MediaSource(url = "", json = ""), + thumbnailSource = null, + fileSize = null, + formattedFileSize = "2.5 MB", + fileExtension = "PDF", + ), + AttachmentItem( + filename = "photo.jpg", + mimeType = "image/jpeg", + mediaSource = io.element.android.libraries.matrix.api.media.MediaSource(url = "", json = ""), + thumbnailSource = io.element.android.libraries.matrix.api.media.MediaSource(url = "thumb", json = ""), + fileSize = null, + formattedFileSize = "1.2 MB", + fileExtension = "JPG", + ), + AttachmentItem( + filename = "spreadsheet.xlsx", + mimeType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + mediaSource = io.element.android.libraries.matrix.api.media.MediaSource(url = "", json = ""), + thumbnailSource = null, + fileSize = null, + formattedFileSize = "450 KB", + fileExtension = "XLSX", + ), + ), + ), + onContentClick = {}, + onContentLayoutChange = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemAttachmentsListViewWithCaptionPreview() = ElementPreview { + TimelineItemAttachmentsListView( + content = TimelineItemAttachmentsContent( + body = "Files", + caption = "Important documents", + formattedCaption = null, + isEdited = false, + attachments = listOf( + AttachmentItem( + filename = "report.pdf", + mimeType = "application/pdf", + mediaSource = io.element.android.libraries.matrix.api.media.MediaSource(url = "", json = ""), + thumbnailSource = null, + fileSize = null, + formattedFileSize = "3.2 MB", + fileExtension = "PDF", + ), + AttachmentItem( + filename = "notes.txt", + mimeType = "text/plain", + mediaSource = io.element.android.libraries.matrix.api.media.MediaSource(url = "", json = ""), + thumbnailSource = null, + fileSize = null, + formattedFileSize = "12 KB", + fileExtension = "TXT", + ), + ), + ), + onContentClick = {}, + onContentLayoutChange = {}, + ) +} \ No newline at end of file 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..b32c4d2276c 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 @@ -15,9 +15,11 @@ import io.element.android.features.messages.impl.timeline.components.layout.Cont 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.TimelineItemAudioContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent @@ -40,6 +42,7 @@ fun TimelineItemEventContentView( content: TimelineItemEventContent, hideMediaContent: Boolean, onContentClick: (() -> Unit)?, + onGalleryItemClick: ((Int) -> Unit)? = null, onLongClick: (() -> Unit)?, onShowContentClick: () -> Unit, onLinkClick: (Link) -> Unit, @@ -90,6 +93,21 @@ fun TimelineItemEventContentView( onContentLayoutChange = onContentLayoutChange, modifier = modifier, ) + is TimelineItemGalleryContent -> TimelineItemGalleryView( + content = content, + onContentClick = { index -> onGalleryItemClick?.invoke(index) }, + onLongClick = onLongClick, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, + onContentLayoutChange = {}, + modifier = modifier, + ) + is TimelineItemAttachmentsContent -> TimelineItemAttachmentsListView( + content = content, + onContentClick = { index -> onGalleryItemClick?.invoke(index) }, + onContentLayoutChange = {}, + modifier = modifier, + ) is TimelineItemStickerContent -> TimelineItemStickerView( content = content, hideMediaContent = hideMediaContent, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt new file mode 100644 index 00000000000..9236154903e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemGalleryView.kt @@ -0,0 +1,741 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.event + +import android.text.SpannedString +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +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.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.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.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.messages.impl.timeline.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.strings.CommonStrings +import io.element.android.libraries.ui.utils.time.formatShort +import io.element.android.wysiwyg.compose.EditorStyledText +import io.element.android.wysiwyg.link.Link +import kotlin.time.Duration.Companion.seconds + +private const val MAX_TILES = 5 +private val GRID_SPACING = 4.dp +private val GROUP_CORNER_RADIUS = 6.dp + +@Composable +fun TimelineItemGalleryView( + content: TimelineItemGalleryContent, + onContentClick: ((Int) -> Unit)?, + onLongClick: (() -> Unit)?, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, + onContentLayoutChange: () -> Unit, + modifier: Modifier = Modifier, +) { + if (content.items.isEmpty()) return + + val totalItems = content.items.size + val showOverflow = totalItems > MAX_TILES - 1 + val overflowCount = totalItems - (MAX_TILES - 1) + + Column(modifier = modifier) { + val containerModifier = Modifier.clip(RoundedCornerShape(GROUP_CORNER_RADIUS)) + + BoxWithConstraints( + modifier = containerModifier.fillMaxWidth(), + ) { + val availableWidth = maxWidth + val squareSize = (availableWidth - GRID_SPACING) / 2 + + Column( + verticalArrangement = Arrangement.spacedBy(GRID_SPACING), + ) { + when { + totalItems == 1 -> { + SingleItemLayout( + item = content.items[0], + availableWidth = availableWidth, + tileHeight = squareSize, + onClick = onContentClick?.let { { it(0) } }, + onLongClick = onLongClick, + ) + } + totalItems == 2 -> { + TwoItemLayout( + items = content.items, + availableWidth = availableWidth, + tileHeight = squareSize, + onContentClick = onContentClick, + onLongClick = onLongClick, + ) + } + totalItems == 3 -> { + ThreeItemLayout( + items = content.items, + availableWidth = availableWidth, + topRowHeight = squareSize, + bottomRowHeight = squareSize, + onContentClick = onContentClick, + onLongClick = onLongClick, + ) + } + totalItems >= 4 -> { + val bottomRowTileCount = minOf(if (showOverflow) 3 else (totalItems - 2), 3) + val topRowTileCount = 2 + val topRowHeight = (availableWidth - GRID_SPACING * (topRowTileCount - 1)) / topRowTileCount + val bottomRowHeight = (availableWidth - GRID_SPACING * (bottomRowTileCount - 1)) / bottomRowTileCount + FourPlusItemLayout( + items = content.items, + showOverflow = showOverflow, + overflowCount = overflowCount, + availableWidth = availableWidth, + topRowHeight = topRowHeight, + bottomRowHeight = bottomRowHeight, + onContentClick = onContentClick, + onLongClick = onLongClick, + ) + } + } + } + } + + if (content.showCaption) { + Spacer(modifier = Modifier.height(8.dp)) + val caption = if (LocalInspectionMode.current) { + SpannedString(content.caption) + } else { + content.formattedCaption ?: SpannedString(content.caption) + } + CompositionLocalProvider( + LocalContentColor provides ElementTheme.colors.textPrimary, + LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular + ) { + EditorStyledText( + modifier = Modifier + .padding(horizontal = 4.dp) + .widthIn(min = 120.dp), + text = caption, + style = ElementRichTextEditorStyle.textStyle(), + onLinkClickedListener = onLinkClick, + onLinkLongClickedListener = onLinkLongClick, + releaseOnDetach = false, + ) + } + } + } +} + +// ── Layout: 1 item ────────────────────────────────────────────────────────────── + +@Composable +private fun SingleItemLayout( + item: GalleryItem, + availableWidth: androidx.compose.ui.unit.Dp, + tileHeight: androidx.compose.ui.unit.Dp, + onClick: (() -> Unit)?, + onLongClick: (() -> Unit)?, +) { + GalleryItemCell( + item = item, + isLast = false, + remainingCount = 0, + onClick = onClick, + onLongClick = onLongClick, + modifier = Modifier + .width(availableWidth) + .height(tileHeight), + ) +} + +// ── Layout: 2 items ───────────────────────────────────────────────────────── + +@Composable +private fun TwoItemLayout( + items: List, + availableWidth: androidx.compose.ui.unit.Dp, + tileHeight: androidx.compose.ui.unit.Dp, + onContentClick: ((Int) -> Unit)?, + onLongClick: (() -> Unit)?, +) { + Column( + modifier = Modifier.width(availableWidth), + verticalArrangement = Arrangement.spacedBy(GRID_SPACING), + ) { + items.forEachIndexed { index, item -> + GalleryItemCell( + item = item, + isLast = false, + remainingCount = 0, + onClick = onContentClick?.let { { it(index) } }, + onLongClick = onLongClick, + modifier = Modifier + .fillMaxWidth() + .height(tileHeight), + ) + } + } +} + +// ── Layout: 3 items: 1 rectangle on top + 2 squares below ───────────────────── + +@Composable +private fun ThreeItemLayout( + items: List, + availableWidth: androidx.compose.ui.unit.Dp, + topRowHeight: androidx.compose.ui.unit.Dp, + bottomRowHeight: androidx.compose.ui.unit.Dp, + onContentClick: ((Int) -> Unit)?, + onLongClick: (() -> Unit)?, +) { + Column( + modifier = Modifier.width(availableWidth), + verticalArrangement = Arrangement.spacedBy(GRID_SPACING), + ) { + GalleryItemCell( + item = items[0], + isLast = false, + remainingCount = 0, + onClick = onContentClick?.let { { it(0) } }, + onLongClick = onLongClick, + modifier = Modifier + .fillMaxWidth() + .height(topRowHeight), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(GRID_SPACING), + ) { + GalleryItemCell( + item = items[1], + isLast = false, + remainingCount = 0, + onClick = onContentClick?.let { { it(1) } }, + onLongClick = onLongClick, + modifier = Modifier + .weight(1f) + .height(bottomRowHeight), + ) + GalleryItemCell( + item = items[2], + isLast = false, + remainingCount = 0, + onClick = onContentClick?.let { { it(2) } }, + onLongClick = onLongClick, + modifier = Modifier + .weight(1f) + .height(bottomRowHeight), + ) + } + } +} + +// ── Layout: 4+ items ───────────────────────────────────────────────────────── + +@Composable +private fun FourPlusItemLayout( + items: List, + showOverflow: Boolean, + overflowCount: Int, + availableWidth: androidx.compose.ui.unit.Dp, + topRowHeight: androidx.compose.ui.unit.Dp, + bottomRowHeight: androidx.compose.ui.unit.Dp, + onContentClick: ((Int) -> Unit)?, + onLongClick: (() -> Unit)?, +) { + Column( + modifier = Modifier.width(availableWidth), + verticalArrangement = Arrangement.spacedBy(GRID_SPACING), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(GRID_SPACING), + ) { + GalleryItemCell( + item = items[0], + isLast = false, + remainingCount = 0, + onClick = onContentClick?.let { { it(0) } }, + onLongClick = onLongClick, + modifier = Modifier + .weight(1f) + .height(topRowHeight), + ) + GalleryItemCell( + item = items[1], + isLast = false, + remainingCount = 0, + onClick = onContentClick?.let { { it(1) } }, + onLongClick = onLongClick, + modifier = Modifier + .weight(1f) + .height(topRowHeight), + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(GRID_SPACING), + ) { + val bottomRowItems = if (showOverflow) 3 else minOf(items.size - 2, 3) + for (i in 0 until bottomRowItems) { + val itemIndex = 2 + i + if (itemIndex < items.size) { + val isOverflowItem = showOverflow && i == bottomRowItems - 1 + GalleryItemCell( + item = items[itemIndex], + isLast = isOverflowItem, + remainingCount = if (isOverflowItem) overflowCount else 0, + onClick = onContentClick?.let { { it(itemIndex) } }, + onLongClick = onLongClick, + modifier = Modifier + .weight(1f) + .height(bottomRowHeight), + ) + } + } + } + } +} + +// ── Gallery item cell ──────────────────────────────────────────────────────── + +@Composable +private fun GalleryItemCell( + item: GalleryItem, + isLast: Boolean, + remainingCount: Int, + onClick: (() -> Unit)?, + onLongClick: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .blurHashBackground(item.blurhash, alpha = 0.9f) + .then( + if (onClick != null) { + Modifier.combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + ) + } else { + Modifier + } + ), + contentAlignment = Alignment.Center, + ) { + AsyncImage( + modifier = Modifier.fillMaxSize(), + model = item.thumbnailMediaRequestData, + contentScale = ContentScale.Crop, + alignment = Alignment.Center, + contentDescription = item.filename, + ) + + if (item.isVideo) { + VideoOverlay(duration = item.duration) + } + + if (item.isAudio) { + AudioIconOverlay() + } + + if (item.isFile) { + FileIconOverlay() + } + + if (isLast && remainingCount > 0) { + RemainingCountOverlay(count = remainingCount) + } + } +} + +// ── Overlays ───────────────────────────────────────────────────────────────── + +@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 AudioIconOverlay() { + Box( + modifier = Modifier + .size(36.dp) + .background( + color = Color.Black.copy(alpha = 0.5f), + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = CompoundIcons.MicOnSolid(), + contentDescription = stringResource(CommonStrings.common_audio), + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + } +} + +@Composable +private fun FileIconOverlay() { + Box( + modifier = Modifier + .size(36.dp) + .background( + color = Color.Black.copy(alpha = 0.5f), + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = CompoundIcons.Attachment(), + contentDescription = stringResource(CommonStrings.common_file), + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + } +} + +@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.fontHeadingXlBold, + ) + } +} + +// ── Previews ───────────────────────────────────────────────────────────────── + +@PreviewsDayNight +@Composable +internal fun TimelineItemGalleryViewPreview() = ElementPreview { + TimelineItemGalleryView( + content = TimelineItemGalleryContent( + body = "Gallery", + caption = "My vacation photos", + formattedCaption = null, + isEdited = false, + items = listOf( + createSampleGalleryItem(isVideo = false), + createSampleGalleryItem(isVideo = true, duration = 65.seconds), + createSampleGalleryItem(isVideo = false), + createSampleGalleryItem(isVideo = false), + ), + ), + onContentClick = {}, + onLongClick = {}, + onLinkClick = {}, + onLinkLongClick = {}, + onContentLayoutChange = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemGalleryViewSingleItemPreview() = ElementPreview { + TimelineItemGalleryView( + content = TimelineItemGalleryContent( + body = "Gallery", + caption = null, + formattedCaption = null, + isEdited = false, + items = listOf( + createSampleGalleryItem(isVideo = false), + ), + ), + onContentClick = {}, + onLongClick = {}, + onLinkClick = {}, + onLinkLongClick = {}, + onContentLayoutChange = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemGalleryViewTwoLandscapePreview() = ElementPreview { + TimelineItemGalleryView( + content = TimelineItemGalleryContent( + body = "Gallery", + caption = null, + formattedCaption = null, + isEdited = false, + items = listOf( + createSampleGalleryItem(width = 1920, height = 1080), + createSampleGalleryItem(width = 1600, height = 900), + ), + ), + onContentClick = {}, + onLongClick = {}, + onLinkClick = {}, + onLinkLongClick = {}, + onContentLayoutChange = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemGalleryViewTwoPortraitPreview() = ElementPreview { + TimelineItemGalleryView( + content = TimelineItemGalleryContent( + body = "Gallery", + caption = null, + formattedCaption = null, + isEdited = false, + items = listOf( + createSampleGalleryItem(width = 1080, height = 1920), + createSampleGalleryItem(width = 900, height = 1600), + ), + ), + onContentClick = {}, + onLongClick = {}, + onLinkClick = {}, + onLinkLongClick = {}, + onContentLayoutChange = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemGalleryViewTwoMixedPreview() = ElementPreview { + TimelineItemGalleryView( + content = TimelineItemGalleryContent( + body = "Gallery", + caption = null, + formattedCaption = null, + isEdited = false, + items = listOf( + createSampleGalleryItem(width = 1920, height = 1080), + createSampleGalleryItem(width = 1080, height = 1920), + ), + ), + onContentClick = {}, + onLongClick = {}, + onLinkClick = {}, + onLinkLongClick = {}, + onContentLayoutChange = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemGalleryViewThreeItemsPreview() = ElementPreview { + TimelineItemGalleryView( + content = TimelineItemGalleryContent( + body = "Gallery", + caption = null, + formattedCaption = null, + isEdited = false, + items = listOf( + createSampleGalleryItem(isVideo = true, duration = 45.seconds), + createSampleGalleryItem(isVideo = false), + createSampleGalleryItem(isVideo = false), + ), + ), + onContentClick = {}, + onLongClick = {}, + onLinkClick = {}, + onLinkLongClick = {}, + onContentLayoutChange = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemGalleryViewThree2L1PPreview() = ElementPreview { + TimelineItemGalleryView( + content = TimelineItemGalleryContent( + body = "Gallery", + caption = "2 landscape + 1 portrait", + formattedCaption = null, + isEdited = false, + items = listOf( + createSampleGalleryItem(width = 1920, height = 1080), + createSampleGalleryItem(width = 1600, height = 900), + createSampleGalleryItem(width = 1080, height = 1920), + ), + ), + onContentClick = {}, + onLongClick = {}, + onLinkClick = {}, + onLinkLongClick = {}, + onContentLayoutChange = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemGalleryViewThree1L2PPreview() = ElementPreview { + TimelineItemGalleryView( + content = TimelineItemGalleryContent( + body = "Gallery", + caption = "1 landscape + 2 portrait", + formattedCaption = null, + isEdited = false, + items = listOf( + createSampleGalleryItem(width = 1920, height = 1080), + createSampleGalleryItem(width = 1080, height = 1920), + createSampleGalleryItem(width = 900, height = 1600), + ), + ), + onContentClick = {}, + onLongClick = {}, + onLinkClick = {}, + onLinkLongClick = {}, + onContentLayoutChange = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemGalleryViewFiveItemsPreview() = ElementPreview { + TimelineItemGalleryView( + content = TimelineItemGalleryContent( + body = "Gallery", + caption = null, + formattedCaption = null, + isEdited = false, + items = listOf( + createSampleGalleryItem(isVideo = false), + createSampleGalleryItem(isVideo = false), + createSampleGalleryItem(isVideo = true, duration = 120.seconds), + createSampleGalleryItem(isVideo = false), + createSampleGalleryItem(isVideo = false), + ), + ), + onContentClick = {}, + onLongClick = {}, + onLinkClick = {}, + onLinkLongClick = {}, + onContentLayoutChange = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemGalleryViewManyItemsPreview() = ElementPreview { + TimelineItemGalleryView( + content = TimelineItemGalleryContent( + body = "Gallery", + caption = "Many photos", + formattedCaption = null, + isEdited = false, + items = (1..12).map { createSampleGalleryItem(isVideo = it == 3) }, + ), + onContentClick = {}, + onLongClick = {}, + onLinkClick = {}, + onLinkLongClick = {}, + onContentLayoutChange = {}, + ) +} + +// ── Sample data helpers ────────────────────────────────────────────────────── + +private fun createSampleGalleryItem( + isVideo: Boolean = false, + width: Int = 400, + height: Int = 300, + duration: kotlin.time.Duration = kotlin.time.Duration.ZERO, +): GalleryItem { + return GalleryItem( + filename = "photo.jpg", + mimeType = "image/jpeg", + mediaSource = io.element.android.libraries.matrix.api.media.MediaSource(url = "", json = ""), + thumbnailSource = null, + width = width, + height = height, + thumbnailWidth = width, + thumbnailHeight = height, + blurhash = null, + isVideo = isVideo, + isAudio = false, + isFile = false, + duration = duration, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt index f5e760736e5..6b29695b8c6 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 @@ -64,6 +68,7 @@ import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH 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.time.formatShort import io.element.android.libraries.ui.utils.time.isTalkbackActive 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..c86181c2b61 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.GalleryItem 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.TimelineItemAttachmentsContent +import io.element.android.features.messages.impl.timeline.model.event.AttachmentItem +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 @@ -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,154 @@ class TimelineItemContentMessageFactory( isEdited = content.isEdited, ) } + is GalleryMessageType -> { + val dom = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser) + val formattedCaption = dom?.let(::parseHtml) + ?: messageType.body.withLinks() + val galleryItems = messageType.items.mapNotNull { item -> + when (item) { + is GalleryItemType.Image -> { + GalleryItem( + filename = item.content.filename, + mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream, + mediaSource = item.content.source, + thumbnailSource = item.content.info?.thumbnailSource, + width = item.content.info?.width?.toInt(), + height = item.content.info?.height?.toInt(), + thumbnailWidth = item.content.info?.thumbnailInfo?.width?.toInt(), + thumbnailHeight = item.content.info?.thumbnailInfo?.height?.toInt(), + blurhash = item.content.info?.blurhash, + isVideo = false, + isAudio = false, + isFile = false, + ) + } + is GalleryItemType.Video -> { + GalleryItem( + filename = item.content.filename, + mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream, + mediaSource = item.content.source, + thumbnailSource = item.content.info?.thumbnailSource, + width = item.content.info?.width?.toInt(), + height = item.content.info?.height?.toInt(), + thumbnailWidth = item.content.info?.thumbnailInfo?.width?.toInt(), + thumbnailHeight = item.content.info?.thumbnailInfo?.height?.toInt(), + blurhash = item.content.info?.blurhash, + isVideo = true, + isAudio = false, + isFile = false, + duration = item.content.info?.duration ?: kotlin.time.Duration.ZERO, + ) + } + is GalleryItemType.Audio -> { + GalleryItem( + filename = item.content.filename, + mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream, + mediaSource = item.content.source, + thumbnailSource = null, + width = null, + height = null, + thumbnailWidth = null, + thumbnailHeight = null, + blurhash = null, + isVideo = false, + isAudio = true, + isFile = false, + ) + } + is GalleryItemType.File -> { + GalleryItem( + filename = item.content.filename, + mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream, + mediaSource = item.content.source, + thumbnailSource = item.content.info?.thumbnailSource, + width = null, + height = null, + thumbnailWidth = null, + thumbnailHeight = null, + blurhash = null, + isVideo = false, + isAudio = false, + isFile = true, + ) + } + is GalleryItemType.Other -> null + } + } + val hasPreviews = galleryItems.any { it.thumbnailSource != null } + // Check if this is a media gallery (images/videos with previews) vs file attachments + val isMediaGallery = galleryItems.all { item -> + item.isVideo || (!item.isAudio && !item.isFile) + } + if (isMediaGallery && hasPreviews) { + // Media gallery - use grid view + TimelineItemGalleryContent( + body = messageType.body, + caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() }, + formattedCaption = formattedCaption, + isEdited = content.isEdited, + items = galleryItems, + ) + } else { + // File attachments - use list view + val attachments = messageType.items.mapNotNull { item -> + when (item) { + is GalleryItemType.File -> { + AttachmentItem( + filename = item.content.filename, + mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream, + mediaSource = item.content.source, + thumbnailSource = item.content.info?.thumbnailSource, + fileSize = item.content.info?.size, + formattedFileSize = fileSizeFormatter.format(item.content.info?.size ?: 0L), + fileExtension = fileExtensionExtractor.extractFromName(item.content.filename), + ) + } + is GalleryItemType.Image -> { + AttachmentItem( + filename = item.content.filename, + mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream, + mediaSource = item.content.source, + thumbnailSource = item.content.info?.thumbnailSource, + fileSize = item.content.info?.size, + formattedFileSize = fileSizeFormatter.format(item.content.info?.size ?: 0L), + fileExtension = fileExtensionExtractor.extractFromName(item.content.filename), + ) + } + is GalleryItemType.Video -> { + AttachmentItem( + filename = item.content.filename, + mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream, + mediaSource = item.content.source, + thumbnailSource = item.content.info?.thumbnailSource, + fileSize = item.content.info?.size, + formattedFileSize = fileSizeFormatter.format(item.content.info?.size ?: 0L), + fileExtension = fileExtensionExtractor.extractFromName(item.content.filename), + ) + } + is GalleryItemType.Audio -> { + AttachmentItem( + filename = item.content.filename, + mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream, + mediaSource = item.content.source, + thumbnailSource = null, + fileSize = item.content.info?.size, + formattedFileSize = fileSizeFormatter.format(item.content.info?.size ?: 0L), + fileExtension = fileExtensionExtractor.extractFromName(item.content.filename), + ) + } + is GalleryItemType.Other -> null + } + } + TimelineItemAttachmentsContent( + body = messageType.body, + caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() }, + formattedCaption = formattedCaption, + isEdited = content.isEdited, + attachments = attachments, + ) + } + } is OtherMessageType -> { val body = messageType.body.trimEnd() TimelineItemTextContent( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt index 6f369417dd3..e612a5a1fa3 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 @@ -10,8 +10,10 @@ 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.TimelineItemAudioContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent 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..81b24e5b15a 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 @@ -11,6 +11,8 @@ 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.TimelineItemEventContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent 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..e24eaaeb7d5 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAttachmentsContent.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model.event + +import io.element.android.libraries.matrix.api.media.MediaSource + +data class TimelineItemAttachmentsContent( + val body: String, + val caption: String?, + val formattedCaption: CharSequence?, + override val isEdited: Boolean, + val attachments: List, +) : TimelineItemEventContent, TimelineItemEventMutableContent { + override val type: String = "TimelineItemAttachmentsContent" + + val showCaption = caption != null + + val hasPreviews = attachments.any { it.thumbnailSource != null } +} + +data class AttachmentItem( + val filename: String, + val mimeType: String, + val mediaSource: MediaSource, + val thumbnailSource: MediaSource?, + val fileSize: Long?, + val formattedFileSize: String, + val fileExtension: String, +) \ No newline at end of file diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt index 9c4c48d11ea..967e7641675 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt @@ -56,7 +56,9 @@ fun TimelineItemEventContent.canBeForwarded(): Boolean = is TimelineItemAudioContent, is TimelineItemVideoContent, is TimelineItemLocationContent, - is TimelineItemVoiceContent -> true + is TimelineItemVoiceContent, + is TimelineItemGalleryContent, + is TimelineItemAttachmentsContent -> true // Stickers can't be forwarded (yet) so we don't show the option // See https://github.com/element-hq/element-x-android/issues/2161 is TimelineItemStickerContent -> false @@ -78,7 +80,9 @@ fun TimelineItemEventContent.canReact(): Boolean = is TimelineItemLocationContent, is TimelineItemPollContent, is TimelineItemVoiceContent, - is TimelineItemVideoContent -> true + is TimelineItemVideoContent, + is TimelineItemGalleryContent, + is TimelineItemAttachmentsContent -> true is TimelineItemStateContent, is TimelineItemRedactedContent, is TimelineItemLegacyCallInviteContent, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt index 44dd2df38d6..0d9be258c77 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt @@ -111,3 +111,83 @@ fun aTimelineItemStateEventContent( ) = TimelineItemStateEventContent( body = body, ) + +fun aTimelineItemGalleryContent( + body: String = "Gallery", + caption: String? = null, + items: List = listOf( + aGalleryItem(), + aGalleryItem(), + aGalleryItem(), + aGalleryItem(), + ), +) = TimelineItemGalleryContent( + body = body, + caption = caption, + formattedCaption = null, + isEdited = false, + items = items, +) + +fun aGalleryItem( + filename: String = "photo.jpg", + width: Int = 400, + height: Int = 300, + isVideo: Boolean = false, + isAudio: Boolean = false, + isFile: Boolean = false, + duration: kotlin.time.Duration = kotlin.time.Duration.ZERO, +) = GalleryItem( + filename = filename, + mimeType = when { + isVideo -> "video/mp4" + isAudio -> "audio/mpeg" + isFile -> "application/pdf" + else -> "image/jpeg" + }, + mediaSource = io.element.android.libraries.matrix.api.media.MediaSource(url = "", json = ""), + thumbnailSource = null, + width = width, + height = height, + thumbnailWidth = width, + thumbnailHeight = height, + blurhash = null, + isVideo = isVideo, + isAudio = isAudio, + isFile = isFile, + duration = duration, +) + +fun aTimelineItemAttachmentsContent( + body: String = "Attachments", + caption: String? = null, + attachments: List = listOf( + anAttachmentItem(filename = "document.pdf", fileExtension = "pdf"), + anAttachmentItem(filename = "recording.mp3", fileExtension = "mp3", fileSize = 4_500_000L, formattedFileSize = "4.5MB"), + ), +) = TimelineItemAttachmentsContent( + body = body, + caption = caption, + formattedCaption = null, + isEdited = false, + attachments = attachments, +) + +fun anAttachmentItem( + filename: String = "file.pdf", + fileExtension: String = "pdf", + fileSize: Long = 1_000_000L, + formattedFileSize: String = "1MB", + hasThumbnail: Boolean = false, +) = AttachmentItem( + filename = filename, + mimeType = when { + hasThumbnail -> "image/jpeg" + else -> "application/$fileExtension" + }, + mediaSource = io.element.android.libraries.matrix.api.media.MediaSource(url = "", json = ""), + thumbnailSource = if (hasThumbnail) io.element.android.libraries.matrix.api.media.MediaSource(url = "", json = "") else null, + fileSize = fileSize, + formattedFileSize = formattedFileSize, + fileExtension = fileExtension, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt new file mode 100644 index 00000000000..1fb48763627 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model.event + +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.ui.media.MediaRequestData +import kotlin.time.Duration + +data class TimelineItemGalleryContent( + val body: String, + val caption: String?, + val formattedCaption: CharSequence?, + override val isEdited: Boolean, + val items: List, +) : TimelineItemEventContent, TimelineItemEventMutableContent { + override val type: String = "TimelineItemGalleryContent" + + val showCaption = caption != null +} + +data class GalleryItem( + val filename: String, + val mimeType: String, + val mediaSource: MediaSource, + val thumbnailSource: MediaSource?, + val width: Int?, + val height: Int?, + val thumbnailWidth: Int?, + val thumbnailHeight: Int?, + val blurhash: String?, + val isVideo: Boolean, + val isAudio: Boolean, + val isFile: Boolean, + val duration: Duration = Duration.ZERO, +) { + val thumbnailMediaRequestData: MediaRequestData by lazy { + MediaRequestData( + source = thumbnailSource ?: mediaSource, + kind = MediaRequestData.Kind.Thumbnail( + width = thumbnailWidth?.toLong() ?: io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH, + height = thumbnailHeight?.toLong() ?: io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT, + ), + ) + } + + val aspectRatio: Float? by lazy { + if (width != null && height != null && height > 0) { + width.toFloat() / height.toFloat() + } else { + null + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineItem.kt index 5a5363f0c68..82dda14015f 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 @@ -10,9 +10,11 @@ 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.TimelineItemAudioContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent @@ -36,6 +38,8 @@ fun TimelineItem.mustBeProtected(): Boolean { return when (this) { is TimelineItem.Event -> when (content) { is TimelineItemImageContent, + is TimelineItemGalleryContent, + is TimelineItemAttachmentsContent, is TimelineItemVideoContent, is TimelineItemStickerContent -> true is TimelineItemAudioContent, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt index c48f2dae402..62a7c2dfeba 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,9 +11,11 @@ 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.TimelineItemAudioContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent 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 @@ -55,6 +57,16 @@ class DefaultMessageSummaryFormatter( is TimelineItemVideoContent -> context.getString(CommonStrings.common_video) is TimelineItemFileContent -> context.getString(CommonStrings.common_file) is TimelineItemAudioContent -> context.getString(CommonStrings.common_audio) + is TimelineItemGalleryContent -> context.getString(CommonStrings.common_image) + is TimelineItemAttachmentsContent -> { + val count = content.attachments.size + val extensions = content.attachments.take(3).joinToString(", ") { it.fileExtension } + if (count == 1) { + extensions + } else { + "$extensions +${count - 3}" + } + } is TimelineItemLegacyCallInviteContent -> context.getString(CommonStrings.common_unsupported_call) is TimelineItemRtcNotificationContent -> context.getString(CommonStrings.common_call_started) } diff --git a/features/messages/impl/src/main/res/values/temporary.xml b/features/messages/impl/src/main/res/values/temporary.xml new file mode 100644 index 00000000000..e7a8a8c7ecd --- /dev/null +++ b/features/messages/impl/src/main/res/values/temporary.xml @@ -0,0 +1,8 @@ + + + + "%1$d item selected" + "%1$d items selected" + + Attachments + diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt index 384b78471d2..01d38549388 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 @@ -114,7 +114,7 @@ class AttachmentsPreviewPresenterTest { assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false)) initialState.eventSink(AttachmentsPreviewEvent.SendAttachment) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = true)) - assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo))) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done) sendFileResult.assertions().isCalledOnce() @@ -150,7 +150,7 @@ class AttachmentsPreviewPresenterTest { advanceUntilIdle() assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false)) initialState.eventSink(AttachmentsPreviewEvent.SendAttachment) - assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo))) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done) sendFileResult.assertions().isCalledOnce() @@ -186,7 +186,7 @@ class AttachmentsPreviewPresenterTest { // Pre-processing finishes processLatch.complete(Unit) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = true)) - assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo))) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done) sendFileResult.assertions().isCalledOnce() @@ -404,18 +404,15 @@ class AttachmentsPreviewPresenterTest { assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) initialState.eventSink(AttachmentsPreviewEvent.SendAttachment) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false)) - assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo))) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo)) - // Check that the onDoneListener is called so the screen would be dismissed - onDoneListenerResult.assertions().isCalledOnce() - val failureState = awaitItem() assertThat(failureState.sendActionState).isEqualTo(SendActionState.Failure(failure, mediaUploadInfo)) sendFileResult.assertions().isCalledOnce() failureState.eventSink(AttachmentsPreviewEvent.CancelAndClearSendState) val clearedState = awaitLastSequentialItem() - assertThat(clearedState.sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) + assertThat(clearedState.sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo))) } } @@ -437,15 +434,12 @@ class AttachmentsPreviewPresenterTest { assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) initialState.eventSink(AttachmentsPreviewEvent.SendAttachment) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false)) - assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo))) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo)) initialState.eventSink(AttachmentsPreviewEvent.CancelAndClearSendState) - assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo))) // The sending is cancelled and the state is kept at ReadyToUpload ensureAllEventsConsumed() - - // Check that the onDoneListener is called so the screen would be dismissed - onDoneListenerResult.assertions().isCalledOnce() } } @@ -575,7 +569,7 @@ class AttachmentsPreviewPresenterTest { mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), ): AttachmentsPreviewPresenter { return AttachmentsPreviewPresenter( - attachment = aMediaAttachment(localMedia), + attachments = persistentListOf(aMediaAttachment(localMedia)), onDoneListener = onDoneListener, mediaSenderFactory = MediaSenderFactory { timelineMode -> DefaultMediaSender( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/SendActionStateTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/SendActionStateTest.kt index 208d9cc7c0a..10b9b918991 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/SendActionStateTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/SendActionStateTest.kt @@ -25,7 +25,7 @@ class SendActionStateTest { @Test fun `mediaUploadInfo() should return the value from ReadyToUpload class`() { val mediaUploadInfo: MediaUploadInfo = aMediaUploadInfo() - val state: SendActionState = SendActionState.Sending.ReadyToUpload(mediaInfo = aMediaUploadInfo()) + val state: SendActionState = SendActionState.Sending.ReadyToUpload(mediaInfos = listOf(aMediaUploadInfo())) assertThat(state.mediaUploadInfo()).isEqualTo(mediaUploadInfo) } diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt index 7878245acaf..4a4c014eaf2 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 @@ -102,6 +103,9 @@ class DefaultPinnedMessagesBannerFormatter( is OtherMessageType -> { messageType.body } + is GalleryMessageType -> { + messageType.body.prefixWith(CommonStrings.common_image) + } is NoticeMessageType -> { messageType.body } diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt index 3ecd7819e52..f54727f94a1 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 @@ -164,6 +165,9 @@ class DefaultRoomLatestEventFormatter( is OtherMessageType -> { messageType.body } + is GalleryMessageType -> { + messageType.body.prefixWith(sp.getString(CommonStrings.common_image)) + } is NoticeMessageType -> { messageType.body } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/GalleryItemInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/GalleryItemInfo.kt new file mode 100644 index 00000000000..446febdbe52 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/GalleryItemInfo.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.media + +import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody +import java.io.File + +sealed interface GalleryItemInfo { + val file: File + val caption: String? + val formattedCaption: FormattedBody? + + data class Image( + override val file: File, + val imageInfo: ImageInfo, + val thumbnailFile: File?, + override val caption: String?, + override val formattedCaption: FormattedBody?, + ) : GalleryItemInfo + + data class Video( + override val file: File, + val videoInfo: VideoInfo, + val thumbnailFile: File?, + override val caption: String?, + override val formattedCaption: FormattedBody?, + ) : GalleryItemInfo + + data class Audio( + override val file: File, + val audioInfo: AudioInfo, + override val caption: String?, + override val formattedCaption: FormattedBody?, + ) : GalleryItemInfo + + data class MediaFile( + override val file: File, + val fileInfo: FileInfo, + override val caption: String?, + override val formattedCaption: FormattedBody?, + ) : GalleryItemInfo +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt index fe73230dce0..35d1acdf2f6 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt @@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.FileInfo +import io.element.android.libraries.matrix.api.media.GalleryItemInfo import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.MediaUploadHandler import io.element.android.libraries.matrix.api.media.VideoInfo @@ -157,6 +158,13 @@ interface Timeline : AutoCloseable { inReplyToEventId: EventId?, ): Result + suspend fun sendGallery( + items: List, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId?, + ): Result + suspend fun redactEvent(eventOrTransactionId: EventOrTransactionId, reason: String?): Result suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt index 0cc5ca0ff74..eff49c88ef3 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt @@ -102,6 +102,21 @@ data class TextMessageType( val formatted: FormattedBody? ) : MessageType +data class GalleryMessageType( + val body: String, + val formatted: FormattedBody?, + val items: List, +) : MessageType + +@Immutable +sealed interface GalleryItemType { + data class Image(val content: ImageMessageType) : GalleryItemType + data class Audio(val content: AudioMessageType) : GalleryItemType + data class Video(val content: VideoMessageType) : GalleryItemType + data class File(val content: FileMessageType) : GalleryItemType + data class Other(val itemtype: String, val body: String) : GalleryItemType +} + data class OtherMessageType( val msgType: String, val body: String, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/FormattedBody.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/FormattedBody.kt new file mode 100644 index 00000000000..015d1bce047 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/FormattedBody.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.media + +import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody +import org.matrix.rustcomponents.sdk.FormattedBody as RustFormattedBody +import org.matrix.rustcomponents.sdk.MessageFormat as RustMessageFormat + +fun FormattedBody.map(): RustFormattedBody = RustFormattedBody( + format = format.map(), + body = body, +) + +private fun io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat.map(): RustMessageFormat { + return when (this) { + io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat.HTML -> RustMessageFormat.Html + io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat.UNKNOWN -> RustMessageFormat.Unknown("") + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryItemInfoMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryItemInfoMapper.kt new file mode 100644 index 00000000000..08bb63a7619 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryItemInfoMapper.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.media + +import io.element.android.libraries.matrix.api.media.GalleryItemInfo +import org.matrix.rustcomponents.sdk.FormattedBody as RustFormattedBody +import org.matrix.rustcomponents.sdk.GalleryItemInfo as RustGalleryItemInfo +import org.matrix.rustcomponents.sdk.UploadSource as RustUploadSource + +fun GalleryItemInfo.map(): RustGalleryItemInfo = when (this) { + is GalleryItemInfo.Image -> { + RustGalleryItemInfo.Image( + imageInfo = imageInfo.map(), + source = RustUploadSource.File(file.path), + caption = caption, + formattedCaption = formattedCaption?.map(), + thumbnailSource = thumbnailFile?.path?.let(RustUploadSource::File), + ) + } + is GalleryItemInfo.Video -> { + RustGalleryItemInfo.Video( + videoInfo = videoInfo.map(), + source = RustUploadSource.File(file.path), + caption = caption, + formattedCaption = formattedCaption?.map(), + thumbnailSource = thumbnailFile?.path?.let(RustUploadSource::File), + ) + } + is GalleryItemInfo.Audio -> { + RustGalleryItemInfo.Audio( + audioInfo = audioInfo.map(), + source = RustUploadSource.File(file.path), + caption = caption, + formattedCaption = formattedCaption?.map(), + ) + } + is GalleryItemInfo.MediaFile -> { + RustGalleryItemInfo.File( + fileInfo = fileInfo.map(), + source = RustUploadSource.File(file.path), + caption = caption, + formattedCaption = formattedCaption?.map(), + ) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryMediaUploadHandlerImpl.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryMediaUploadHandlerImpl.kt new file mode 100644 index 00000000000..ee3b1140d34 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryMediaUploadHandlerImpl.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.media + +import io.element.android.libraries.androidutils.file.safeDelete +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.media.MediaUploadHandler +import org.matrix.rustcomponents.sdk.SendGalleryJoinHandle +import java.io.File + +class GalleryMediaUploadHandlerImpl( + private val filesToUpload: List, + private val sendGalleryJoinHandle: SendGalleryJoinHandle, +) : MediaUploadHandler { + override suspend fun await(): Result = + runCatchingExceptions { + sendGalleryJoinHandle.join() + } + .also { cleanUpFiles() } + + override fun cancel() { + sendGalleryJoinHandle.cancel() + cleanUpFiles() + } + + private fun cleanUpFiles() { + filesToUpload.forEach { file -> file.safeDelete() } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt index 7e65a1cc5cb..1b00a7a4b27 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,7 @@ 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 as org.matrix.rustcomponents.sdk.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 6507cf38c83..9149ca5e57f 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 @@ -216,6 +216,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 7b398529a05..4a87d1fb36b 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 @@ -14,6 +14,7 @@ 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.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 @@ -29,6 +30,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 @@ -65,6 +67,8 @@ 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.GalleryItemInfo as RustGalleryItemInfo +import org.matrix.rustcomponents.sdk.GalleryUploadParameters import org.matrix.rustcomponents.sdk.MessageFormat import org.matrix.rustcomponents.sdk.PollData import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle @@ -525,6 +529,42 @@ class RustTimeline( } } + override suspend fun sendGallery( + items: List, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId?, + ): Result { + Timber.d("Sending gallery with ${items.size} items") + val allFiles = items.flatMap { item -> + when (item) { + is GalleryItemInfo.Image -> listOfNotNull(item.file, item.thumbnailFile) + is GalleryItemInfo.Video -> listOfNotNull(item.file, item.thumbnailFile) + is GalleryItemInfo.Audio -> listOf(item.file) + is GalleryItemInfo.MediaFile -> listOf(item.file) + } + } + return sendGalleryAttachment(allFiles) { + inner.sendGallery( + params = GalleryUploadParameters( + caption = caption, + formattedCaption = formattedCaption?.let { + FormattedBody(body = it, format = MessageFormat.Html) + }, + mentions = null, + inReplyTo = inReplyToEventId?.value, + ), + itemInfos = items.map { it.map() }, + ) + } + } + + private fun sendGalleryAttachment(files: List, handle: () -> org.matrix.rustcomponents.sdk.SendGalleryJoinHandle): Result { + return runCatchingExceptions { + GalleryMediaUploadHandlerImpl(files, handle()) + } + } + override suspend fun createPoll( question: String, answers: List, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt index e382bce8db9..0cc213adfba 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt @@ -13,6 +13,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageT import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody +import io.element.android.libraries.matrix.api.timeline.item.event.GalleryItemType +import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType @@ -31,12 +33,10 @@ import org.matrix.rustcomponents.sdk.MessageType import org.matrix.rustcomponents.sdk.MsgLikeKind import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.FormattedBody as RustFormattedBody +import org.matrix.rustcomponents.sdk.GalleryItemType as RustGalleryItemType import org.matrix.rustcomponents.sdk.MessageFormat as RustMessageFormat import org.matrix.rustcomponents.sdk.MessageType as RustMessageType -// https://github.com/Johennes/matrix-spec-proposals/blob/johannes/msgtype-galleries/proposals/4274-inline-media-galleries.md#unstable-prefix -private const val MSG_TYPE_GALLERY_UNSTABLE = "dm.filament.gallery" - class EventMessageMapper { private val inReplyToMapper by lazy { InReplyToMapper(TimelineEventContentMapper()) } @@ -124,8 +124,64 @@ class EventMessageMapper { OtherMessageType(type.msgtype, type.body) } is MessageType.Gallery -> { - // TODO expose the GalleryType. - OtherMessageType(MSG_TYPE_GALLERY_UNSTABLE, type.content.body) + GalleryMessageType( + body = type.content.body, + formatted = type.content.formatted?.map(), + items = type.content.itemtypes.map { mapGalleryItemType(it) }, + ) + } + } + + private fun mapGalleryItemType(type: RustGalleryItemType): GalleryItemType = when (type) { + is RustGalleryItemType.Image -> { + GalleryItemType.Image( + content = ImageMessageType( + filename = type.content.filename, + caption = type.content.caption, + formattedCaption = type.content.formattedCaption?.map(), + source = type.content.source.map(), + info = type.content.info?.map(), + ) + ) + } + is RustGalleryItemType.Audio -> { + GalleryItemType.Audio( + content = AudioMessageType( + filename = type.content.filename, + caption = type.content.caption, + formattedCaption = type.content.formattedCaption?.map(), + source = type.content.source.map(), + info = type.content.info?.map(), + ) + ) + } + is RustGalleryItemType.Video -> { + GalleryItemType.Video( + content = VideoMessageType( + filename = type.content.filename, + caption = type.content.caption, + formattedCaption = type.content.formattedCaption?.map(), + source = type.content.source.map(), + info = type.content.info?.map(), + ) + ) + } + is RustGalleryItemType.File -> { + GalleryItemType.File( + content = FileMessageType( + filename = type.content.filename, + caption = type.content.caption, + formattedCaption = type.content.formattedCaption?.map(), + source = type.content.source.map(), + info = type.content.info?.map(), + ) + ) + } + is RustGalleryItemType.Other -> { + GalleryItemType.Other( + itemtype = type.itemtype, + body = type.body, + ) } } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt index fcc7057dbe5..dd55849cfdf 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 @@ -293,6 +293,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..0327243c05c 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,8 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Immutable import io.element.android.libraries.core.mimetype.MimeTypes +private const val MAX_GALLERY_ITEMS = 20 + @Immutable sealed interface PickerType { fun getContract(): ActivityResultContract @@ -34,6 +36,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 +65,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..e1b224dbad3 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,25 @@ 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> { + // 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(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. diff --git a/libraries/mediapickers/test/src/main/kotlin/io/element/android/libraries/mediapickers/test/FakePickerProvider.kt b/libraries/mediapickers/test/src/main/kotlin/io/element/android/libraries/mediapickers/test/FakePickerProvider.kt index 98cbcec1ea5..5d3be8acd17 100644 --- a/libraries/mediapickers/test/src/main/kotlin/io/element/android/libraries/mediapickers/test/FakePickerProvider.kt +++ b/libraries/mediapickers/test/src/main/kotlin/io/element/android/libraries/mediapickers/test/FakePickerProvider.kt @@ -30,11 +30,21 @@ class FakePickerProvider : PickerProvider { return NoOpPickerLauncher { onResult(result) } } + @Composable + override fun registerGalleryMultiPicker(onResult: (uris: List) -> Unit): PickerLauncher> { + return NoOpPickerLauncher { onResult(result?.let { listOf(it) } ?: emptyList()) } + } + @Composable override fun registerFilePicker(mimeType: String, onResult: (Uri?, String?) -> Unit): PickerLauncher { return NoOpPickerLauncher { onResult(result, this.mimeType) } } + @Composable + override fun registerFileMultiPicker(mimeType: String, onResult: (uris: List) -> Unit): PickerLauncher, List> { + return NoOpPickerLauncher { onResult(result?.let { listOf(it) } ?: emptyList()) } + } + @Composable override fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher { return NoOpPickerLauncher { onResult(result) } diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt index 628e760c1e5..6cca73935e8 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt @@ -61,5 +61,12 @@ interface MediaSender { inReplyToEventId: EventId? = null, ): Result + suspend fun sendGallery( + mediaUploadInfos: List, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId?, + ): Result + fun cleanUp() } diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt index f0082a4d1b4..0f05bc315bc 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt @@ -10,6 +10,7 @@ package io.element.android.libraries.mediaupload.api import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.FileInfo +import io.element.android.libraries.matrix.api.media.GalleryItemInfo import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.VideoInfo import java.io.File @@ -31,3 +32,40 @@ fun MediaUploadInfo.allFiles(): List { (this@allFiles as? MediaUploadInfo.Video)?.thumbnailFile, ) } + +fun MediaUploadInfo.toGalleryItemInfo(caption: String?, formattedCaption: String?): GalleryItemInfo { + return when (this) { + is MediaUploadInfo.Image -> GalleryItemInfo.Image( + file = file, + imageInfo = imageInfo, + thumbnailFile = thumbnailFile, + caption = caption, + formattedCaption = null, + ) + is MediaUploadInfo.Video -> GalleryItemInfo.Video( + file = file, + videoInfo = videoInfo, + thumbnailFile = thumbnailFile, + caption = caption, + formattedCaption = null, + ) + is MediaUploadInfo.Audio -> GalleryItemInfo.Audio( + file = file, + audioInfo = audioInfo, + caption = caption, + formattedCaption = null, + ) + is MediaUploadInfo.VoiceMessage -> GalleryItemInfo.Audio( + file = file, + audioInfo = audioInfo, + caption = caption, + formattedCaption = null, + ) + is MediaUploadInfo.AnyFile -> GalleryItemInfo.MediaFile( + file = file, + fileInfo = fileInfo, + caption = caption, + formattedCaption = null, + ) + } +} diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt index ea2cac29517..d7da5f01949 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt @@ -27,6 +27,7 @@ import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSenderFactory import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory import io.element.android.libraries.mediaupload.api.MediaUploadInfo +import io.element.android.libraries.mediaupload.api.toGalleryItemInfo import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import timber.log.Timber @@ -167,6 +168,29 @@ class DefaultMediaSender( .handleSendResult(mediaId(uri)) } + override suspend fun sendGallery( + mediaUploadInfos: List, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId?, + ): Result { + val galleryLogId = "gallery[${mediaUploadInfos.size} items]" + Timber.d("Sending $galleryLogId") + return getTimeline().flatMap { timeline -> + val galleryItems = mediaUploadInfos.map { it.toGalleryItemInfo(null, null) } + timeline.sendGallery( + items = galleryItems, + caption = caption, + formattedCaption = formattedCaption, + inReplyToEventId = inReplyToEventId, + ) + } + .flatMapCatching { uploadHandler -> + uploadHandler.await() + } + .handleSendResult(galleryLogId) + } + private fun Result.handleSendResult(mediaId: String) = this .onFailure { error -> val job = ongoingUploadJobs.remove(Job) diff --git a/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaSender.kt b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaSender.kt index 1713f7b5eaf..6ef8da4ab6e 100644 --- a/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaSender.kt +++ b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaSender.kt @@ -19,6 +19,7 @@ class FakeMediaSender( private val sendPreProcessedMediaResult: () -> Result = { lambdaError() }, private val sendMediaResult: () -> Result = { lambdaError() }, private val sendVoiceMessageResult: () -> Result = { lambdaError() }, + private val sendGalleryResult: () -> Result = { lambdaError() }, private val cleanUpResult: () -> Unit = { lambdaError() }, ) : MediaSender { override suspend fun preProcessMedia( @@ -58,6 +59,15 @@ class FakeMediaSender( return sendVoiceMessageResult() } + override suspend fun sendGallery( + mediaUploadInfos: List, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId?, + ): Result { + return sendGalleryResult() + } + override fun cleanUp() { cleanUpResult() } diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/GalleryItemData.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/GalleryItemData.kt new file mode 100644 index 00000000000..fd3ec5637a0 --- /dev/null +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/GalleryItemData.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.api + +import android.os.Parcelable +import io.element.android.libraries.matrix.api.media.MediaSource +import kotlinx.parcelize.Parcelize + +@Parcelize +data class GalleryItemData( + val filename: String, + val mimeType: String, + val mediaSource: MediaSource, + val thumbnailSource: MediaSource?, + val isVideo: Boolean, + val isAudio: Boolean, + val isFile: Boolean, +) : Parcelable diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt index 536930b9682..05e6d1bdb50 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt @@ -42,6 +42,7 @@ interface MediaViewerEntryPoint : FeatureEntryPoint { val mediaSource: MediaSource, val thumbnailSource: MediaSource?, val canShowInfo: Boolean, + val galleryItems: List = emptyList(), ) : NodeInputs sealed interface MediaViewerMode : Parcelable { diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt index 67b73d616d8..24b1c42def5 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,56 @@ 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(c.filename, c.info?.size, c.caption, c.info?.mimetype.orEmpty(), c.filename, event, dateSent, dateSentFull), + mediaSource = c.source, + thumbnailSource = c.info?.thumbnailSource, + ) + } + is GalleryItemType.Video -> { + val c = galleryItem.content + MediaItem.Video( + id = id, + eventId = currentTimelineItem.eventId, + mediaInfo = createMediaInfo(c.filename, c.info?.size, c.caption, c.info?.mimetype.orEmpty(), c.filename, event, dateSent, 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(c.filename, c.info?.size, c.caption, c.info?.mimetype.orEmpty(), c.filename, event, dateSent, dateSentFull), + mediaSource = c.source, + ) + } + is GalleryItemType.File -> { + val c = galleryItem.content + MediaItem.File( + id = id, + eventId = currentTimelineItem.eventId, + mediaInfo = createMediaInfo(c.filename, c.info?.size, c.caption, c.info?.mimetype.orEmpty(), c.filename, event, dateSent, dateSentFull), + mediaSource = c.source, + ) + } + is GalleryItemType.Other -> null + } + } + } + is AudioMessageType -> listOf(MediaItem.Audio( id = currentTimelineItem.uniqueId, eventId = currentTimelineItem.eventId, mediaInfo = MediaInfo( @@ -110,8 +160,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( @@ -131,8 +181,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( @@ -152,8 +202,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( @@ -173,8 +223,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( @@ -194,8 +244,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( @@ -214,9 +264,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..d81776746a1 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 @@ -1,6 +1,6 @@ /* * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. + * Copyright 2023, 2024 New Vector Ltd. * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * Please see LICENSE files in the repository root for full details. @@ -9,9 +9,6 @@ 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.timeline.MatrixTimelineItem import io.element.android.libraries.mediaviewer.impl.model.MediaItem @@ -32,18 +29,6 @@ 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 - } - } val timelineItems: Flow> = _timelineItems.distinctUntilChanged() @@ -51,39 +36,24 @@ 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 -> { + // create() returns a list to support gallery expansion into multiple items + // Reverse the list since we're iterating timeline items in reverse + val items = eventItemFactory.create(currentTimelineItem) + newTimelineItemStates.addAll(items.asReversed()) + } + is MatrixTimelineItem.Virtual -> { + virtualItemFactory.create(currentTimelineItem)?.also { + newTimelineItemStates.add(it) + } + } + MatrixTimelineItem.Other -> Unit } - } else { - newTimelineItemStates.add(cacheItem) } + _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 } } 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..282c5277fe9 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/GalleryMediaGalleryDataSource.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.viewer + +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.mediaviewer.api.GalleryItemData +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint +import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource +import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.flowOf + +class GalleryMediaGalleryDataSource( + private val data: GroupedMediaItems, +) : MediaGalleryDataSource { + override fun start() = Unit + override fun groupedMediaItemsFlow() = flowOf(AsyncData.Success(data)) + override fun getLastData(): AsyncData = AsyncData.Success(data) + override suspend fun loadMore(direction: Timeline.PaginationDirection) = Unit + override suspend fun deleteItem(eventId: EventId) = Unit + + companion object { + fun createFrom( + eventId: EventId?, + galleryItems: List, + mediaInfo: MediaInfo, + mode: MediaViewerEntryPoint.MediaViewerMode, + ): GalleryMediaGalleryDataSource { + val imageAndVideoItems = mutableListOf() + val fileItems = mutableListOf() + + galleryItems.forEachIndexed { index, galleryItem -> + val itemMediaInfo = MediaInfo( + filename = galleryItem.filename, + fileSize = null, + caption = mediaInfo.caption, + mimeType = galleryItem.mimeType, + formattedFileSize = "", + fileExtension = galleryItem.filename.substringAfterLast('.', ""), + senderId = mediaInfo.senderId, + senderName = mediaInfo.senderName, + senderAvatar = mediaInfo.senderAvatar, + dateSent = mediaInfo.dateSent, + dateSentFull = mediaInfo.dateSentFull, + waveform = null, + duration = null, + ) + val id = UniqueId("${eventId?.value ?: "gallery"}_$index") + val mediaItem: MediaItem.Event = when { + galleryItem.isVideo -> MediaItem.Video( + id = id, + eventId = eventId, + mediaInfo = itemMediaInfo, + mediaSource = galleryItem.mediaSource, + thumbnailSource = galleryItem.thumbnailSource, + ) + galleryItem.isAudio -> MediaItem.Audio( + id = id, + eventId = eventId, + mediaInfo = itemMediaInfo, + mediaSource = galleryItem.mediaSource, + ) + galleryItem.isFile -> MediaItem.File( + id = id, + eventId = eventId, + mediaInfo = itemMediaInfo, + mediaSource = galleryItem.mediaSource, + ) + else -> MediaItem.Image( + id = id, + eventId = eventId, + mediaInfo = itemMediaInfo, + mediaSource = galleryItem.mediaSource, + thumbnailSource = galleryItem.thumbnailSource, + ) + } + when (mediaItem) { + is MediaItem.Image, is MediaItem.Video -> imageAndVideoItems.add(mediaItem) + is MediaItem.Audio, is MediaItem.File, is MediaItem.Voice -> fileItems.add(mediaItem) + } + } + + return GalleryMediaGalleryDataSource( + data = GroupedMediaItems( + imageAndVideoItems = imageAndVideoItems.toImmutableList(), + fileItems = fileItems.toImmutableList(), + ) + ) + } + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt index d834534e3e5..c6cd4f0ce78 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 @@ -75,6 +75,13 @@ class MediaViewerNode( private val mediaGallerySource = if (inputs.mode == MediaViewerEntryPoint.MediaViewerMode.SingleMedia) { SingleMediaGalleryDataSource.createFrom(inputs) + } else if (inputs.galleryItems.isNotEmpty()) { + GalleryMediaGalleryDataSource.createFrom( + eventId = inputs.eventId, + galleryItems = inputs.galleryItems, + mediaInfo = inputs.mediaInfo, + mode = inputs.mode, + ) } else { val eventId = inputs.eventId if (eventId == null) { diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt index ae581fa8d4e..5b50c97f584 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 @@ -301,6 +301,16 @@ class MediaViewerPresenter( if (eventId == null) { return 0 } + // First try to match both eventId and mediaSource (for gallery items that share the same eventId) + val mediaSource = inputs.mediaSource + val exactMatch = data.indexOfFirst { + val pageData = it as? MediaViewerPageData.MediaViewerData + pageData?.eventId == eventId && pageData.mediaSource == mediaSource + } + if (exactMatch >= 0) { + return exactMatch + } + // Fall back to matching only eventId return data.indexOfFirst { (it as? MediaViewerPageData.MediaViewerData)?.eventId == eventId }.coerceAtLeast(0) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/PagerKeysHandler.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/PagerKeysHandler.kt index a2234143aff..d1f063e64dc 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,11 +51,14 @@ 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 @@ -62,13 +66,16 @@ class PagerKeysHandler { } else { val cachedItem = cachedData.mediaItems[itemInCacheIndex] val eventId = (cachedItem as? MediaItem.Event)?.eventId() - if (eventId == null) { + val cachedSourceUrl = (cachedItem as? MediaItem.Event)?.mediaSource()?.safeUrl + if (eventId == null || cachedSourceUrl == null) { // Should not happen, but in this case, start with a new cache 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 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 6c88f1c33f1..67860527871 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 @@ -96,7 +96,7 @@ class DefaultEventItemFactoryTest { ) ) ) - assertThat(result).isNull() + assertThat(result).isEmpty() } } @@ -121,7 +121,7 @@ class DefaultEventItemFactoryTest { ) ) ) - assertThat(result).isNull() + assertThat(result).isEmpty() } } @@ -149,7 +149,7 @@ class DefaultEventItemFactoryTest { ) ) ) - assertThat(result).isEqualTo( + assertThat(result).containsExactly( MediaItem.File( id = A_UNIQUE_ID, eventId = AN_EVENT_ID, @@ -200,7 +200,7 @@ class DefaultEventItemFactoryTest { ) ) ) - assertThat(result).isEqualTo( + assertThat(result).containsExactly( MediaItem.Image( id = A_UNIQUE_ID, eventId = AN_EVENT_ID, @@ -248,7 +248,7 @@ class DefaultEventItemFactoryTest { ) ) ) - assertThat(result).isEqualTo( + assertThat(result).containsExactly( MediaItem.Audio( id = A_UNIQUE_ID, eventId = AN_EVENT_ID, @@ -300,7 +300,7 @@ class DefaultEventItemFactoryTest { ) ) ) - assertThat(result).isEqualTo( + assertThat(result).containsExactly( MediaItem.Video( id = A_UNIQUE_ID, eventId = AN_EVENT_ID, @@ -352,7 +352,7 @@ class DefaultEventItemFactoryTest { ) ) ) - assertThat(result).isEqualTo( + assertThat(result).containsExactly( MediaItem.Voice( id = A_UNIQUE_ID, eventId = AN_EVENT_ID, @@ -403,7 +403,7 @@ class DefaultEventItemFactoryTest { ) ) ) - assertThat(result).isEqualTo( + assertThat(result).containsExactly( MediaItem.Image( id = A_UNIQUE_ID, eventId = AN_EVENT_ID, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt index 64583bd1d44..2f3fa9b0b75 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt @@ -41,6 +41,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageT import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EventType import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType @@ -344,6 +345,7 @@ class DefaultNotifiableEventResolver( is TextMessageType -> messageType.toPlainText(permalinkParser = permalinkParser) is VideoMessageType -> messageType.bestDescription is LocationMessageType -> messageType.body + is GalleryMessageType -> messageType.body is OtherMessageType -> messageType.body } } diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListViewWithCaption_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListViewWithCaption_Day_0_en.png new file mode 100644 index 00000000000..0cdaa898a73 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListViewWithCaption_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:967b45d81a59b1aef737f7d486dc4510967bc74fedfa7163448399d702a2d201 +size 16172 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListViewWithCaption_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListViewWithCaption_Night_0_en.png new file mode 100644 index 00000000000..2401ce59a86 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemAttachmentsListViewWithCaption_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7c0205b990e7b168690d5ed60b72ade03862968045abea3de0dad8dab31e0262 +size 15485 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..2d4ff894615 --- /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:19a733346f842b091c1d49af166c7978ac8f82e04986a766f3894641015c2900 +size 29821 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..a7ad57f60a6 --- /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:65d934d94d30806e84c313ecd42de19478af1b373dad3f68957af67e523d2dcc +size 29014 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewFiveItems_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewFiveItems_Day_0_en.png new file mode 100644 index 00000000000..f867205f592 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewFiveItems_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5aa9146c6ef66a5f0ad24b8da2a1a0caebaa0c4fcd55a875760f99bcbfa62878 +size 491420 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewFiveItems_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewFiveItems_Night_0_en.png new file mode 100644 index 00000000000..6c19778de5b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewFiveItems_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9fdf198f6f2f1edd35cfc57c0e165a1c25ad07e7504ed24aabce48cc81deeac4 +size 485630 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewManyItems_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewManyItems_Day_0_en.png new file mode 100644 index 00000000000..7acc93e1778 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewManyItems_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fcc7e9b65c172e626fb41e7f3b58dea9f5967b10738a8a6ea2deec38d4aeffe9 +size 493979 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewManyItems_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewManyItems_Night_0_en.png new file mode 100644 index 00000000000..fe84da2e0f5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewManyItems_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:de450daaa9fbab5117c37021fda9ee1b57db0797ff08c6d49f52770a5cda3ed3 +size 487567 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewSingleItem_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewSingleItem_Day_0_en.png new file mode 100644 index 00000000000..4436d6521a1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewSingleItem_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:061b63923a07f516f391fb80d64b74efbd68f2f8f4d20e073f3e9385f2b9e86f +size 312697 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewSingleItem_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewSingleItem_Night_0_en.png new file mode 100644 index 00000000000..933a2d68c95 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewSingleItem_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:61cab0d8a77827355180cd1cc88241dfb114c02addcafed8c5f6e784de80f4d4 +size 312575 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree1L2P_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree1L2P_Day_0_en.png new file mode 100644 index 00000000000..9d494e986a4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree1L2P_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a843f9ab44a8cfd9438f747b5b4baf09cfe4826dd361aa60eef9c7d46b1556ee +size 593506 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree1L2P_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree1L2P_Night_0_en.png new file mode 100644 index 00000000000..58bd8b110f0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree1L2P_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df6859788214e9d3e99d7366fc7db8c68524cfbf9f0c9c5e1fd0b754015bc3f5 +size 593158 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree2L1P_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree2L1P_Day_0_en.png new file mode 100644 index 00000000000..29495b8b92b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree2L1P_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b1c6b598f9c6d9d90c11819fcc02e28ca78681262a651552c523ad65b531de5a +size 593519 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree2L1P_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree2L1P_Night_0_en.png new file mode 100644 index 00000000000..6dcaeaf1174 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThree2L1P_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5fe03904b494bc5b382ecceea0c6228270f5582e9943bbe9af4a70ad68a87dfa +size 593150 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThreeItems_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThreeItems_Day_0_en.png new file mode 100644 index 00000000000..9846fdec745 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThreeItems_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0bd2efef0ce0a2e757e28beff7b47c6edc6559be6d45048735c49d6c52a15884 +size 570543 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThreeItems_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThreeItems_Night_0_en.png new file mode 100644 index 00000000000..1079def82b8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewThreeItems_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:42119536ee8caee59098a1b7fd72befb85be47f422d26038cafe8a98b1162af9 +size 567904 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoLandscape_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoLandscape_Day_0_en.png new file mode 100644 index 00000000000..026fbd3272d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoLandscape_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7b82d2bc41ce655cbc6ae495812c615f855d847a14105c62eecf1d6a724b740a +size 623598 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoLandscape_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoLandscape_Night_0_en.png new file mode 100644 index 00000000000..0c1fe53be43 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoLandscape_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2d017ee91b6f67f839ee0ccd5c3ea5b4facab5e087203eead1eeea753fb3bf8b +size 623369 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoMixed_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoMixed_Day_0_en.png new file mode 100644 index 00000000000..026fbd3272d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoMixed_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7b82d2bc41ce655cbc6ae495812c615f855d847a14105c62eecf1d6a724b740a +size 623598 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoMixed_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoMixed_Night_0_en.png new file mode 100644 index 00000000000..0c1fe53be43 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoMixed_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2d017ee91b6f67f839ee0ccd5c3ea5b4facab5e087203eead1eeea753fb3bf8b +size 623369 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoPortrait_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoPortrait_Day_0_en.png new file mode 100644 index 00000000000..026fbd3272d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoPortrait_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7b82d2bc41ce655cbc6ae495812c615f855d847a14105c62eecf1d6a724b740a +size 623598 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoPortrait_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoPortrait_Night_0_en.png new file mode 100644 index 00000000000..0c1fe53be43 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemGalleryViewTwoPortrait_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2d017ee91b6f67f839ee0ccd5c3ea5b4facab5e087203eead1eeea753fb3bf8b +size 623369 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..a01b20b5508 --- /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:1ee37806ec4f750421359f73e06eb268b442a254c078ab31e6076c26dc991475 +size 579730 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..c4325816521 --- /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:62cb248053a89f2b9cf82e5d4c377f0cafdc6b58202ff74cd2e454d2db064857 +size 576017 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsAudio_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsAudio_Day_0_en.png new file mode 100644 index 00000000000..7c2a806bd8b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsAudio_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e156f888b6626ef198cf5af90ed219ba8d0635062b6cf2be294f584b32b8b725 +size 39211 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsAudio_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsAudio_Night_0_en.png new file mode 100644 index 00000000000..c284bb2314d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsAudio_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8ca7bbd322c3fbde1d7ec906801aa4a3e4918b2a024d0c23bfc20198fb08cabe +size 38676 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsImages_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsImages_Day_0_en.png new file mode 100644 index 00000000000..d9781df6a47 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsImages_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ac685b88aa521c8544722c98caa36199bf3f741ba9df7d15572c900becdf22bd +size 103641 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsImages_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsImages_Night_0_en.png new file mode 100644 index 00000000000..6e5b30ed950 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsImages_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0e13985ddbbce127e613e798e06f25b7019edcd5e874b38596c8ec46502ad399 +size 102596 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsVideos_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsVideos_Day_0_en.png new file mode 100644 index 00000000000..b0c02c323d2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsVideos_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:36fa15135c46449a2d71ddaaff8e00d100bd5c50dab2bffc7506c746df44f4a8 +size 76313 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsVideos_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsVideos_Night_0_en.png new file mode 100644 index 00000000000..9187fe898af --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachmentsVideos_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ef631a715b6c853cdc2b84f0b8e8da03568f4a1a47d27f662e864236dcce4652 +size 75691 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachments_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachments_Day_0_en.png new file mode 100644 index 00000000000..0cb2258b388 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachments_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cd63e8e9827038418da92d9507d7e089ee0ffd12c8ef72e73016d8aba9a3f703 +size 48454 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachments_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachments_Night_0_en.png new file mode 100644 index 00000000000..afa6519116d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithAttachments_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ad2af86c7a50d48af8f18b5cc8e710e016c923a0be09c255bb0d25db44a7c275 +size 47989 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryManyItems_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryManyItems_Day_0_en.png new file mode 100644 index 00000000000..b1db0c54a21 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryManyItems_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8b2114c32a9c594f1122298002693aa81694c53706aa01d0547685167256da36 +size 608829 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryManyItems_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryManyItems_Night_0_en.png new file mode 100644 index 00000000000..07727d47a89 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryManyItems_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3ec39cba1a7e556aae6cba056a86f1cf7cbdfd534ca79b521c6a2d4125a0517c +size 603176 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryThreeItems_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryThreeItems_Day_0_en.png new file mode 100644 index 00000000000..3fd3572ae45 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryThreeItems_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3e93262045831f73e2105b680f799a15faa6e702f0a5186bedd6177310354c5e +size 754241 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryThreeItems_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryThreeItems_Night_0_en.png new file mode 100644 index 00000000000..ca6ac0b4404 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryThreeItems_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2746f5337bc5d02be15dca7f5c9272e6157efe58b32c089e099edb41e5065f9f +size 752876 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryTwoItems_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryTwoItems_Day_0_en.png new file mode 100644 index 00000000000..9a1cce5390b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryTwoItems_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6ad617dfd7fc2c904b7a927719c8571e3e09eaee62706255bdfb6b359126b140 +size 768586 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryTwoItems_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryTwoItems_Night_0_en.png new file mode 100644 index 00000000000..7ceac5e7043 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryTwoItems_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:881831622af8642c37a86164214a01a1f64118572ed975467ef0cc65cb1bfafd +size 767200 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryVideoItems_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryVideoItems_Day_0_en.png new file mode 100644 index 00000000000..4e39c497e55 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryVideoItems_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d726335322cb26c506edfc43366e48c00748f4568bbea73948557bece9dcd6cb +size 722782 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryVideoItems_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryVideoItems_Night_0_en.png new file mode 100644 index 00000000000..220585c5c14 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithGalleryVideoItems_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1647f4fe0bf00e177aecf4d328e2674d5f9e67f97734ec7def036cbe479fbc81 +size 709036 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..8261bc29999 --- /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:4a2205f431a8b9eca9ff1ead79c6a3451429aa0b2df3823785722a6cc8d47dcb +size 742590 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..29f715c9014 --- /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:b175c886b2c74dd2ad4cef3751590af0386212cb199d5be337b9de546ae70126 +size 741308