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