Merge branch 'develop' into feat/polls-max-selections

This commit is contained in:
Benoit Marty
2026-08-07 14:03:17 +02:00
committed by GitHub
4915 changed files with 74016 additions and 22432 deletions
+7 -2
View File
@@ -51,8 +51,9 @@ dependencies {
implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.recentemojis.api)
implementation(projects.libraries.emoji.api)
implementation(projects.libraries.roomselect.api)
implementation(projects.libraries.slashcommands.api)
implementation(projects.libraries.audio.api)
implementation(projects.libraries.voiceplayer.api)
implementation(projects.libraries.voicerecorder.api)
@@ -61,6 +62,7 @@ dependencies {
implementation(projects.libraries.uiUtils)
implementation(projects.libraries.testtags)
implementation(projects.features.networkmonitor.api)
implementation(projects.features.contentscanner.api)
implementation(projects.services.analytics.compose)
implementation(projects.services.appnavstate.api)
implementation(projects.services.toolbox.api)
@@ -69,6 +71,7 @@ dependencies {
implementation(libs.jsoup)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.constraintlayout.compose)
implementation(libs.androidx.exifinterface)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui)
implementation(libs.sigpwned.emoji4j)
@@ -103,5 +106,7 @@ dependencies {
testImplementation(projects.libraries.testtags)
testImplementation(projects.features.poll.test)
testImplementation(projects.libraries.eventformatter.test)
testImplementation(projects.libraries.recentemojis.test)
testImplementation(projects.libraries.emoji.impl)
testImplementation(projects.libraries.emoji.test)
testImplementation(projects.libraries.slashcommands.test)
}
@@ -18,6 +18,8 @@ sealed interface MessagesEvent {
data class ToggleReaction(val emoji: String, val eventOrTransactionId: EventOrTransactionId) : MessagesEvent
data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvent
data class OnUserClicked(val user: MatrixUser) : MessagesEvent
data object StopLiveLocationShare : MessagesEvent
data object ShowLiveLocationShare : MessagesEvent
data object MarkAsFullyReadAndExit : MessagesEvent
}
@@ -11,6 +11,9 @@ package io.element.android.features.messages.impl
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.lifecycle.subscribe
@@ -24,11 +27,12 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.annotations.ContributesNode
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint
import io.element.android.features.location.api.LocationService
import io.element.android.features.location.api.RenderingMapsNotSupportedDialog
import io.element.android.features.location.api.ShareLocationEntryPoint
import io.element.android.features.location.api.ShowLocationEntryPoint
import io.element.android.features.location.api.ShowLocationMode
@@ -39,19 +43,25 @@ import io.element.android.features.messages.impl.pinned.DefaultPinnedEventsTimel
import io.element.android.features.messages.impl.pinned.list.PinnedMessagesListNode
import io.element.android.features.messages.impl.report.ReportMessageNode
import io.element.android.features.messages.impl.threads.ThreadedMessagesNode
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
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.blurHash
import io.element.android.features.messages.impl.timeline.model.event.duration
import io.element.android.features.poll.api.create.CreatePollEntryPoint
import io.element.android.features.poll.api.create.CreatePollMode
import io.element.android.libraries.androidutils.system.DeviceHasVulkanSupport
import io.element.android.libraries.architecture.BackstackWithOverlayBox
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.callback
@@ -80,6 +90,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
@@ -120,6 +132,7 @@ class MessagesFlowNode(
private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint,
private val dateFormatter: DateFormatter,
private val coroutineDispatchers: CoroutineDispatchers,
private val hasVulkanSupport: DeviceHasVulkanSupport,
) : BaseFlowNode<MessagesFlowNode.NavTarget>(
backstack = BackStack(
initialElement = plugins.filterIsInstance<MessagesEntryPoint.Params>().first().initialTarget.toNavTarget(),
@@ -142,10 +155,21 @@ class MessagesFlowNode(
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val canUseOverlay: Boolean,
val blurHash: String?,
) : NavTarget
@Parcelize
data class AttachmentPreview(val timelineMode: Timeline.Mode, val attachment: Attachment, val inReplyToEventId: EventId?) : NavTarget
data class GalleryViewer(
val fromPinnedMessages: Boolean,
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
@@ -179,10 +203,18 @@ class MessagesFlowNode(
@Parcelize
data class Thread(val threadRootId: ThreadId, val focusedEventId: EventId?) : NavTarget
@Parcelize
data object ThreadsList : NavTarget
@Parcelize
data class AvatarPreview(val name: String, val avatarUrl: String) : NavTarget
}
private val callback: MessagesEntryPoint.Callback = callback()
private var displayVulkanNotSupportedError by mutableStateOf(false)
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
@@ -223,17 +255,36 @@ class MessagesFlowNode(
callback.navigateToRoomDetails()
}
override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean {
override fun handleEventClick(
timelineMode: Timeline.Mode,
event: TimelineItem.Event,
canUseOverlay: Boolean,
): Boolean {
return processEventClick(
timelineMode = timelineMode,
event = event,
canUseOverlay = canUseOverlay,
)
}
override fun handleGalleryItemClick(
timelineMode: Timeline.Mode,
event: TimelineItem.Event,
galleryItemIndex: Int,
canUseOverlay: Boolean,
): Boolean {
return processGalleryEventClick(
timelineMode = timelineMode,
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,
)
@@ -261,7 +312,11 @@ class MessagesFlowNode(
}
override fun navigateToSendLocation() {
backstack.push(NavTarget.SendLocation(Timeline.Mode.Live))
if (hasVulkanSupport()) {
backstack.push(NavTarget.SendLocation(Timeline.Mode.Live))
} else {
displayVulkanNotSupportedError = true
}
}
override fun navigateToCreatePoll() {
@@ -272,14 +327,22 @@ class MessagesFlowNode(
backstack.push(NavTarget.EditPoll(Timeline.Mode.Live, eventId))
}
override fun navigateToCurrentLiveLocation() {
if (hasVulkanSupport()) {
backstack.push(NavTarget.LocationViewer(ShowLocationMode.Live(senderId = sessionId)))
} else {
displayVulkanNotSupportedError = true
}
}
override fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) {
val callType = CallType.RoomCall(
val callData = CallData(
sessionId = sessionId,
roomId = roomId,
isAudioCall = isAudioCall
isAudioCall = isAudioCall,
)
analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton)
elementCallEntryPoint.startCall(callType)
elementCallEntryPoint.startCall(callData)
}
override fun navigateToPinnedMessagesList() {
@@ -293,22 +356,70 @@ class MessagesFlowNode(
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
backstack.push(NavTarget.Thread(threadRootId, focusedEventId))
}
override fun navigateToThreadsList() {
backstack.push(NavTarget.ThreadsList)
}
override fun navigateToDeveloperSettings() {
callback.navigateToDeveloperSettings()
}
override fun navigateToAvatarPreview(username: String, avatarUrl: String) {
overlay.show(NavTarget.AvatarPreview(username, avatarUrl))
}
}
val inputs = MessagesNode.Inputs(focusedEventId = navTarget.focusedEventId)
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,
blurHash = navTarget.blurHash,
)
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() {
overlay.hide()
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,
fromPinnedMessages = navTarget.fromPinnedMessages,
)
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() {
if (navTarget.canUseOverlay) {
overlay.hide()
} else {
backstack.pop()
}
}
override fun viewInTimeline(eventId: EventId) {
@@ -329,7 +440,7 @@ class MessagesFlowNode(
}
is NavTarget.AttachmentPreview -> {
val inputs = AttachmentsPreviewNode.Inputs(
attachment = navTarget.attachment,
attachments = navTarget.attachments,
timelineMode = navTarget.timelineMode,
inReplyToEventId = navTarget.inReplyToEventId,
)
@@ -402,10 +513,24 @@ class MessagesFlowNode(
}
NavTarget.PinnedMessagesList -> {
val callback = object : PinnedMessagesListNode.Callback {
override fun handleEventClick(event: TimelineItem.Event) {
override fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean) {
processEventClick(
timelineMode = Timeline.Mode.PinnedEvents,
event = event,
canUseOverlay = canUseOverlay,
)
}
override fun handleGalleryItemClick(
event: TimelineItem.Event,
galleryItemIndex: Int,
canUseOverlay: Boolean,
) {
processGalleryEventClick(
timelineMode = Timeline.Mode.PinnedEvents,
event = event,
galleryItemIndex = galleryItemIndex,
canUseOverlay = canUseOverlay,
)
}
@@ -428,6 +553,10 @@ class MessagesFlowNode(
override fun handleForwardEventClick(eventId: EventId) {
backstack.push(NavTarget.ForwardEvent(eventId = eventId, fromPinnedEvents = true))
}
override fun navigateToThread(threadRootId: ThreadId) {
backstack.push(NavTarget.Thread(threadRootId, null))
}
}
createNode<PinnedMessagesListNode>(buildContext, plugins = listOf(callback))
}
@@ -440,17 +569,36 @@ class MessagesFlowNode(
focusedEventId = navTarget.focusedEventId,
)
val callback = object : ThreadedMessagesNode.Callback {
override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean {
override fun handleEventClick(
timelineMode: Timeline.Mode,
event: TimelineItem.Event,
canUseOverlay: Boolean,
): Boolean {
return processEventClick(
timelineMode = timelineMode,
event = event,
canUseOverlay = canUseOverlay,
)
}
override fun handleGalleryItemClick(
timelineMode: Timeline.Mode,
event: TimelineItem.Event,
galleryItemIndex: Int,
canUseOverlay: Boolean,
): Boolean {
return processGalleryEventClick(
timelineMode = timelineMode,
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,
)
@@ -478,7 +626,11 @@ class MessagesFlowNode(
}
override fun navigateToSendLocation() {
backstack.push(NavTarget.SendLocation(Timeline.Mode.Thread(navTarget.threadRootId)))
if (hasVulkanSupport()) {
backstack.push(NavTarget.SendLocation(Timeline.Mode.Thread(navTarget.threadRootId)))
} else {
displayVulkanNotSupportedError = true
}
}
override fun navigateToCreatePoll() {
@@ -489,22 +641,71 @@ class MessagesFlowNode(
backstack.push(NavTarget.EditPoll(Timeline.Mode.Thread(navTarget.threadRootId), eventId))
}
override fun navigateToCurrentLiveLocation() {
if (hasVulkanSupport()) {
backstack.push(NavTarget.LocationViewer(ShowLocationMode.Live(senderId = sessionId)))
} else {
displayVulkanNotSupportedError = true
}
}
override fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) {
val callType = CallType.RoomCall(
val callData = CallData(
sessionId = sessionId,
roomId = roomId,
isAudioCall = isAudioCall
)
analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton)
elementCallEntryPoint.startCall(callType)
elementCallEntryPoint.startCall(callData)
}
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
backstack.push(NavTarget.Thread(threadRootId, focusedEventId))
}
override fun navigateToDeveloperSettings() {
callback.navigateToDeveloperSettings()
}
override fun navigateToAvatarPreview(username: String, avatarUrl: String) {
overlay.show(NavTarget.AvatarPreview(username, avatarUrl))
}
}
createNode<ThreadedMessagesNode>(buildContext, listOf(inputs, callback))
}
NavTarget.ThreadsList -> {
val callback = object : ThreadsListNode.Callback {
override fun openThread(threadId: ThreadId) {
backstack.push(NavTarget.Thread(threadId, focusedEventId = null))
}
}
createNode<ThreadsListNode>(buildContext, listOf(callback))
}
is NavTarget.AvatarPreview -> {
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() {
overlay.hide()
}
override fun viewInTimeline(eventId: EventId) {
// Cannot happen
}
override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) {
// Cannot happen
}
}
val params = mediaViewerEntryPoint.createParamsForAvatar(
filename = navTarget.name,
avatarUrl = navTarget.avatarUrl,
)
mediaViewerEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = params,
callback = callback,
)
}
}
}
@@ -519,6 +720,7 @@ class MessagesFlowNode(
private fun processEventClick(
timelineMode: Timeline.Mode,
event: TimelineItem.Event,
canUseOverlay: Boolean,
): Boolean {
val navTarget = when (event.content) {
is TimelineItemImageContent -> {
@@ -528,6 +730,7 @@ class MessagesFlowNode(
content = event.content,
mediaSource = event.content.mediaSource,
thumbnailSource = event.content.thumbnailSource,
canUseOverlay = canUseOverlay,
)
}
is TimelineItemVideoContent -> {
@@ -537,6 +740,7 @@ class MessagesFlowNode(
content = event.content,
mediaSource = event.content.mediaSource,
thumbnailSource = event.content.thumbnailSource,
canUseOverlay = canUseOverlay,
)
}
is TimelineItemFileContent -> {
@@ -546,6 +750,7 @@ class MessagesFlowNode(
content = event.content,
mediaSource = event.content.mediaSource,
thumbnailSource = event.content.thumbnailSource,
canUseOverlay = canUseOverlay,
)
}
is TimelineItemAudioContent -> {
@@ -555,26 +760,37 @@ class MessagesFlowNode(
content = event.content,
mediaSource = event.content.mediaSource,
thumbnailSource = null,
canUseOverlay = canUseOverlay,
)
}
is TimelineItemLocationContent -> {
val mode = ShowLocationMode.Static(
location = event.content.location,
senderName = event.safeSenderName,
senderId = event.senderId,
senderAvatarUrl = event.senderAvatar.url,
timestamp = event.sentTimeMillis,
assetType = event.content.assetType,
)
NavTarget.LocationViewer(
mode = mode
).takeIf { locationService.isServiceAvailable() }
if (hasVulkanSupport()) {
val mode = when (event.content.mode) {
is TimelineItemLocationContent.Mode.Live -> ShowLocationMode.Live(event.senderId)
is TimelineItemLocationContent.Mode.Static -> ShowLocationMode.Static(
location = event.content.mode.location,
senderName = event.safeSenderName,
senderId = event.senderId,
senderAvatarUrl = event.senderAvatar.url,
timestamp = event.sentTimeMillis,
assetType = event.content.assetType,
)
}
NavTarget.LocationViewer(mode = mode).takeIf { locationService.isServiceAvailable() }
} else {
displayVulkanNotSupportedError = true
null
}
}
else -> null
}
return when (navTarget) {
is NavTarget.MediaViewer -> {
overlay.show(navTarget)
if (canUseOverlay) {
overlay.show(navTarget)
} else {
backstack.push(navTarget)
}
true
}
is NavTarget.LocationViewer -> {
@@ -585,12 +801,101 @@ class MessagesFlowNode(
}
}
private fun processGalleryEventClick(
timelineMode: Timeline.Mode,
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,
)
val galleryItems = event.content.items.map { galleryItem ->
GalleryItemData(
filename = galleryItem.filename,
mimeType = galleryItem.mimeType,
mediaSource = galleryItem.mediaSource,
thumbnailSource = galleryItem.thumbnailSource,
type = galleryItem.type.toMediaViewerType(),
blurHash = galleryItem.blurhash,
)
}.reversed()
NavTarget.GalleryViewer(
eventId = event.eventId,
galleryInfo = galleryInfo,
canUseOverlay = canUseOverlay,
galleryItems = galleryItems,
fromPinnedMessages = timelineMode is Timeline.Mode.PinnedEvents
)
}
is TimelineItemAttachmentsContent -> {
val galleryInfo = GalleryInfo(
caption = event.content.caption,
senderId = event.senderId,
senderName = event.safeSenderName,
senderAvatar = event.senderAvatar.url,
dateSent = dateFormatter.format(
event.sentTimeMillis,
mode = DateFormatterMode.Day,
),
dateSentFull = dateFormatter.format(
timestamp = event.sentTimeMillis,
mode = DateFormatterMode.Full,
),
initialIndex = galleryItemIndex,
)
val galleryItems = event.content.attachments.map { attachment ->
GalleryItemData(
filename = attachment.filename,
mimeType = attachment.mimeType,
mediaSource = attachment.mediaSource,
thumbnailSource = attachment.thumbnailSource,
type = GalleryItemData.Type.File,
blurHash = null,
)
}.reversed()
NavTarget.GalleryViewer(
eventId = event.eventId,
galleryInfo = galleryInfo,
canUseOverlay = canUseOverlay,
galleryItems = galleryItems,
fromPinnedMessages = timelineMode is Timeline.Mode.PinnedEvents
)
}
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,
content: TimelineItemEventContentWithAttachment,
mediaSource: MediaSource,
thumbnailSource: MediaSource?,
canUseOverlay: Boolean,
): NavTarget {
return NavTarget.MediaViewer(
mode = mode,
@@ -599,6 +904,7 @@ class MessagesFlowNode(
filename = content.filename,
fileSize = content.fileSize,
caption = content.caption,
formattedCaption = content.formattedCaption,
mimeType = content.mimeType,
formattedFileSize = content.formattedFileSize,
fileExtension = content.fileExtension,
@@ -618,6 +924,8 @@ class MessagesFlowNode(
),
mediaSource = mediaSource,
thumbnailSource = thumbnailSource,
canUseOverlay = canUseOverlay,
blurHash = content.blurHash(),
)
}
@@ -634,6 +942,11 @@ class MessagesFlowNode(
@Composable
override fun View(modifier: Modifier) {
mentionSpanTheme.updateStyles()
if (displayVulkanNotSupportedError) {
RenderingMapsNotSupportedDialog { displayVulkanNotSupportedError = false }
}
CompositionLocalProvider(
LocalMentionSpanUpdater provides mentionSpanUpdater
) {
@@ -641,3 +954,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
}
}
@@ -23,6 +23,9 @@ interface MessagesNavigator {
fun navigateToEditPoll(eventId: EventId)
fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
fun navigateToRoom(roomId: RoomId, eventId: EventId?, serverNames: List<String>)
fun navigateToMember(userId: UserId)
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
fun navigateToDeveloperSettings()
fun navigateToCurrentLiveLocation()
fun close()
}
@@ -38,6 +38,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvent
import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@@ -54,6 +55,7 @@ import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
@@ -66,8 +68,13 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.alias.matches
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.media.contentvalidation.EventContentValidationCache
import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState
import io.element.android.libraries.matrix.ui.model.getBestName
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.a11y.hasExternalKeyboard
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.LoadMessagesUi
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.finishLongRunningTransaction
@@ -94,6 +101,8 @@ class MessagesNode(
private val permalinkParser: PermalinkParser,
private val knockRequestsBannerRenderer: KnockRequestsBannerRenderer,
private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
private val eventContentValidationCache: EventContentValidationCache,
private val emojiPickerRenderer: EmojiPickerRenderer,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
data class Inputs(
val focusedEventId: EventId?,
@@ -105,7 +114,7 @@ class MessagesNode(
private val timelineController = TimelineController(room, room.liveTimeline)
private val presenter = presenterFactory.create(
navigator = this,
composerPresenter = messageComposerPresenterFactory.create(timelineController, this),
composerPresenter = messageComposerPresenterFactory.create(timelineController, this, threadRoot = null),
timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this),
actionListPresenter = actionListPresenterFactory.create(
postProcessor = TimelineItemActionPostProcessor.Default,
@@ -115,7 +124,8 @@ class MessagesNode(
)
interface Callback : Plugin {
fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean
fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean
fun handleGalleryItemClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, galleryItemIndex: Int, canUseOverlay: Boolean): Boolean
fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
fun navigateToRoomMemberDetails(userId: UserId)
fun handlePermalinkClick(data: PermalinkData)
@@ -125,11 +135,17 @@ class MessagesNode(
fun navigateToSendLocation()
fun navigateToCreatePoll()
fun navigateToEditPoll(eventId: EventId)
fun navigateToCurrentLiveLocation()
fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean)
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
fun navigateToRoomDetails()
fun navigateToPinnedMessagesList()
fun navigateToKnockRequestsList()
fun navigateToDeveloperSettings()
fun navigateToThreadsList()
fun navigateToAvatarPreview(username: String, avatarUrl: String)
}
override fun onBuilt() {
@@ -222,10 +238,22 @@ class MessagesNode(
}
}
override fun navigateToMember(userId: UserId) {
callback.navigateToRoomMemberDetails(userId)
}
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
callback.navigateToThread(threadRootId, focusedEventId)
}
override fun navigateToDeveloperSettings() {
callback.navigateToDeveloperSettings()
}
override fun navigateToCurrentLiveLocation() {
callback.navigateToCurrentLiveLocation()
}
private fun displaySameRoomToast() {
context.toast(CommonStrings.screen_room_permalink_same_room_android)
}
@@ -236,8 +264,10 @@ class MessagesNode(
override fun View(modifier: Modifier) {
val activity = requireNotNull(LocalActivity.current)
val isDark = ElementTheme.isLightTheme.not()
val canUseOverlay = !isTalkbackActive() && !hasExternalKeyboard()
CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
LocalEventContentValidationState provides eventContentValidationCache,
) {
val state = presenter.present()
@@ -257,11 +287,23 @@ class MessagesNode(
onRoomDetailsClick = callback::navigateToRoomDetails,
onEventContentClick = { isLive, event ->
if (isLive) {
callback.handleEventClick(timelineController.mainTimelineMode(), event)
callback.handleEventClick(timelineController.mainTimelineMode(), event, canUseOverlay)
} else {
val detachedTimelineMode = timelineController.detachedTimelineMode()
if (detachedTimelineMode != null) {
callback.handleEventClick(detachedTimelineMode, event)
callback.handleEventClick(detachedTimelineMode, event, canUseOverlay)
} else {
false
}
}
},
onGalleryEventItemClick = { isLive, event, index ->
if (isLive) {
callback.handleGalleryItemClick(timelineController.mainTimelineMode(), event, index, canUseOverlay)
} else {
val detachedTimelineMode = timelineController.detachedTimelineMode()
if (detachedTimelineMode != null) {
callback.handleGalleryItemClick(detachedTimelineMode, event, index, canUseOverlay)
} else {
false
}
@@ -290,6 +332,16 @@ class MessagesNode(
onViewRequestsClick = callback::navigateToKnockRequestsList,
)
},
customReactionBottomSheet = {
CustomReactionBottomSheet(
state = state.customReactionState,
onSelectEmoji = { uniqueId, emoji ->
state.eventSink(MessagesEvent.ToggleReaction(emoji.unicode, uniqueId))
},
emojiPickerRenderer = emojiPickerRenderer,
)
},
onThreadsListClick = callback::navigateToThreadsList,
)
roomMemberModerationRenderer.Render(
state = state.roomMemberModerationState,
@@ -299,6 +351,11 @@ class MessagesNode(
else -> state.roomMemberModerationState.eventSink(RoomMemberModerationEvents.ProcessAction(action, target))
}
},
onAvatarClick = { user ->
user.avatarUrl?.let { url ->
callback.navigateToAvatarPreview(user.getBestName(), url)
}
},
modifier = Modifier,
)
@@ -16,6 +16,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
@@ -26,7 +27,10 @@ import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.PinUnpinAction
import io.element.android.appconfig.MessageComposerConfig
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
import io.element.android.features.location.api.live.isCurrentlySharing
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.features.messages.impl.MessagesState.Threads
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
@@ -43,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
@@ -65,6 +69,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatch
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.emoji.api.recentemojis.AddRecentEmoji
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.toThreadId
@@ -75,18 +80,21 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.matrix.ui.model.dmUserStatus
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import io.element.android.libraries.recentemojis.api.AddRecentEmoji
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
@@ -121,6 +129,7 @@ class MessagesPresenter(
private val featureFlagService: FeatureFlagService,
private val addRecentEmoji: AddRecentEmoji,
private val markAsFullyRead: MarkAsFullyRead,
private val liveLocationShareManager: ActiveLiveLocationShareManager,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
) : Presenter<MessagesState> {
@AssistedFactory
@@ -160,6 +169,14 @@ class MessagesPresenter(
val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present()
val roomCallState = roomCallStatePresenter.present()
val roomMemberModerationState = roomMemberModerationPresenter.present()
val threadsList by produceState(persistentListOf()) {
room.threadsListService.subscribeToItemUpdates()
.onStart { room.threadsListService.paginate() }
.collectLatest { value = it.toImmutableList() }
}
val canOpenThreadList by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomThreadList).collectAsState(initial = false)
val isCurrentlySharingLiveLocationInRoom by remember { liveLocationShareManager.isCurrentlySharing(room.roomId) }.collectAsState()
val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
perms.userEventPermissions()
@@ -205,12 +222,10 @@ class MessagesPresenter(
val dmRoomMember by room.getDirectRoomMember(membersState)
val roomMemberIdentityStateChanges = identityChangeState.roomMemberIdentityStateChanges
val isKeyShareOnInviteEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.EnableKeyShareOnInvite).collectAsState(initial = false)
// The top bar should show a "history" icon if:
// * History sharing is enabled,
// * The room is encrypted, and:
// * The room's history_visibility allows future users to see content.
val topBarSharedHistoryIcon = if (isKeyShareOnInviteEnabled) roomInfo.sharedHistoryIcon() else SharedHistoryIcon.NONE
val topBarSharedHistoryIcon = roomInfo.sharedHistoryIcon()
LifecycleResumeEffect(dmRoomMember, roomInfo.isEncrypted) {
if (roomInfo.isEncrypted == true) {
@@ -250,12 +265,23 @@ class MessagesPresenter(
is MessagesEvent.OnUserClicked -> {
roomMemberModerationState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(event.user))
}
is MessagesEvent.MarkAsFullyReadAndExit -> coroutineScope.launch {
if (!markingAsReadAndExiting.getAndSet(true)) {
MessagesEvent.StopLiveLocationShare -> {
localCoroutineScope.launch {
liveLocationShareManager.stopShare(room.roomId)
.onFailure {
Timber.e(it, "Failed to stop live location share for roomId=${room.roomId}")
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_error))
}
}
}
MessagesEvent.ShowLiveLocationShare -> {
navigator.navigateToCurrentLiveLocation()
}
is MessagesEvent.MarkAsFullyReadAndExit -> if (!markingAsReadAndExiting.getAndSet(true)) {
coroutineScope.launch {
val latestEventId = room.liveTimeline.getLatestEventId().getOrElse {
Timber.w(it, "Failed to get latest event id to mark as fully read")
navigator.close()
return@launch
null
}
latestEventId?.let { eventId ->
sessionCoroutineScope.launch {
@@ -263,6 +289,7 @@ class MessagesPresenter(
}
}
navigator.close()
}.invokeOnCompletion {
markingAsReadAndExiting.set(false)
}
}
@@ -293,9 +320,16 @@ class MessagesPresenter(
appName = buildMeta.applicationName,
pinnedMessagesBannerState = pinnedMessagesBannerState,
dmUserVerificationState = dmUserVerificationState,
dmUserStatus = roomInfo.dmUserStatus(),
roomMemberModerationState = roomMemberModerationState,
topBarSharedHistoryIcon = topBarSharedHistoryIcon,
successorRoom = roomInfo.successorRoom,
threads = Threads(
hasThreads = canOpenThreadList && threadsList.isNotEmpty(),
// TODO calculate this properly based on the thread list and the read state of each thread
hasUnreadThreads = false,
),
showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom && timelineState.timelineMode !is Timeline.Mode.Thread,
eventSink = ::handleEvent,
)
}
@@ -501,7 +535,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)
@@ -518,7 +552,7 @@ class MessagesPresenter(
val replyToDetails = loadReplyDetails(targetEvent.eventId).map(permalinkParser)
val composerMode = MessageComposerMode.Reply(
replyToDetails = replyToDetails,
hideImage = timelineProtectionState.hideMediaContent(targetEvent.eventId),
hideImage = timelineProtectionState.hideMediaContent(targetEvent.eventId, targetEvent.isMine),
)
composerState.eventSink(
MessageComposerEvent.SetMode(composerMode)
@@ -574,7 +608,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))
@@ -27,6 +27,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import kotlinx.collections.immutable.ImmutableList
data class MessagesState(
@@ -53,13 +54,21 @@ data class MessagesState(
val appName: String,
val pinnedMessagesBannerState: PinnedMessagesBannerState,
val dmUserVerificationState: IdentityState?,
val dmUserStatus: DisplayedStatus?,
val roomMemberModerationState: RoomMemberModerationState,
/** Type of "shared history" icon to show in the top bar. */
val topBarSharedHistoryIcon: SharedHistoryIcon,
val successorRoom: SuccessorRoom?,
val threads: Threads,
val showLiveLocationShareBanner: Boolean,
val eventSink: (MessagesEvent) -> Unit
) {
val isTombstoned = successorRoom != null
data class Threads(
val hasThreads: Boolean,
val hasUnreadThreads: Boolean,
)
}
/** Type of "shared history" icon to show in the top bar. */
@@ -44,15 +44,16 @@ import io.element.android.features.roommembermoderation.api.RoomMemberModeration
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ROOM_NAME
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
@@ -79,6 +80,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
currentPinnedMessageIndex = 0,
),
),
aMessagesState(isCurrentlySharingLiveLocationInRoom = true),
aMessagesState(successorRoom = SuccessorRoom(RoomId("!id:domain"), null)),
aMessagesState(
timelineState = aTimelineState(
@@ -94,8 +96,8 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
}
fun aMessagesState(
roomName: String? = "Room name",
roomAvatar: AvatarData = AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom),
roomName: String? = ROOM_NAME,
roomAvatar: AvatarData = AvatarData("!id:domain", ROOM_NAME, size = AvatarSize.TimelineRoom),
userEventPermissions: UserEventPermissions = aUserEventPermissions(),
composerState: MessageComposerState = aMessageComposerState(
textEditorState = aTextEditorStateRich(initialText = "Hello", initialFocus = true),
@@ -122,6 +124,12 @@ fun aMessagesState(
roomMemberModerationState: RoomMemberModerationState = aRoomMemberModerationState(),
topBarSharedHistoryIcon: SharedHistoryIcon = SharedHistoryIcon.NONE,
successorRoom: SuccessorRoom? = null,
threads: MessagesState.Threads = MessagesState.Threads(
hasThreads = false,
hasUnreadThreads = false,
),
isCurrentlySharingLiveLocationInRoom: Boolean = false,
dmUserStatus: DisplayedStatus? = null,
eventSink: (MessagesEvent) -> Unit = {},
) = MessagesState(
roomId = RoomId("!id:domain"),
@@ -150,6 +158,9 @@ fun aMessagesState(
roomMemberModerationState = roomMemberModerationState,
topBarSharedHistoryIcon = topBarSharedHistoryIcon,
successorRoom = successorRoom,
threads = threads,
showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom,
dmUserStatus = dmUserStatus,
eventSink = eventSink,
)
@@ -184,11 +195,9 @@ fun aReactionSummaryState(
fun aCustomReactionState(
target: CustomReactionState.Target = CustomReactionState.Target.None,
recentEmojis: ImmutableList<String> = persistentListOf(),
eventSink: (CustomReactionEvent) -> Unit = {},
) = CustomReactionState(
target = target,
recentEmojis = recentEmojis,
selectedEmoji = persistentSetOf(),
eventSink = eventSink,
)
@@ -12,20 +12,27 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
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.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -39,6 +46,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
@@ -51,6 +59,8 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.location.api.LiveLocationSharingBanner
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvent
import io.element.android.features.messages.impl.actionlist.ActionListEvent
import io.element.android.features.messages.impl.actionlist.ActionListView
@@ -73,7 +83,7 @@ import io.element.android.features.messages.impl.timeline.aGroupedEvents
import io.element.android.features.messages.impl.timeline.aTimelineItemDaySeparator
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineState
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
import io.element.android.features.messages.impl.timeline.components.CallMenuItem
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView
@@ -87,6 +97,7 @@ import io.element.android.features.messages.impl.topbars.MessagesViewTopBar
import io.element.android.features.messages.impl.topbars.ThreadTopBar
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.androidutils.ui.hideKeyboard
import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule
import io.element.android.libraries.designsystem.components.ExpandableBottomSheetLayout
@@ -98,11 +109,13 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toAnnotatedString
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.BottomSheetDragHandle
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.HideKeyboardWhenDisposed
import io.element.android.libraries.designsystem.utils.KeepScreenOn
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.EventId
@@ -113,6 +126,8 @@ import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState
import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.link.Link
@@ -126,16 +141,21 @@ fun MessagesView(
onBackClick: () -> Unit,
onRoomDetailsClick: () -> Unit,
onEventContentClick: (isLive: Boolean, event: TimelineItem.Event) -> Boolean,
onGalleryEventItemClick: (isLive: Boolean, event: TimelineItem.Event, index: Int) -> Boolean,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String, Boolean) -> Unit,
onSendLocationClick: () -> Unit,
onCreatePollClick: () -> Unit,
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
onViewAllPinnedMessagesClick: () -> Unit,
onThreadsListClick: () -> Unit,
knockRequestsBannerView: @Composable () -> Unit,
modifier: Modifier = Modifier,
forceJumpToBottomVisibility: Boolean = false,
knockRequestsBannerView: @Composable () -> Unit,
customReactionBottomSheet: @Composable () -> Unit,
) {
val eventContentValidationState = LocalEventContentValidationState.current
OnLifecycleEvent { _, event ->
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.LifecycleEvent(event))
}
@@ -158,6 +178,9 @@ fun MessagesView(
fun onContentClick(event: TimelineItem.Event) {
Timber.v("onMessageClick= ${event.id}")
val eventId = event.eventId ?: return
if (eventContentValidationState[eventId].getCurrentOverallState() != ContentValidationValue.Valid) return
val hideKeyboard = onEventContentClick(state.timelineState.isLive, event)
if (hideKeyboard) {
localView.hideKeyboard()
@@ -207,7 +230,7 @@ fun MessagesView(
},
content = {
Scaffold(
contentWindowInsets = WindowInsets.statusBars,
contentWindowInsets = scaffoldScrollableContentInsets,
topBar = {
if (state.timelineState.timelineMode is Timeline.Mode.Thread) {
ThreadTopBar(
@@ -223,12 +246,19 @@ fun MessagesView(
roomAvatar = state.roomAvatar,
isTombstoned = state.isTombstoned,
heroes = state.heroes,
roomCallState = state.roomCallState,
dmUserIdentityState = state.dmUserVerificationState,
sharedHistoryIcon = state.topBarSharedHistoryIcon,
dmUserStatus = state.dmUserStatus,
onBackClick = { hidingKeyboard { onBackClick() } },
onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } },
onJoinCallClick = onJoinCallClick,
menuActions = {
MessagesMenuActions(
displayThreads = state.timelineState.timelineMode !is Timeline.Mode.Thread && state.threads.hasThreads,
roomCallState = state.roomCallState,
onJoinCallClick = onJoinCallClick,
onThreadsListClick = onThreadsListClick
)
}
)
}
},
@@ -241,6 +271,16 @@ fun MessagesView(
MessagesViewContent(
state = state,
onContentClick = ::onContentClick,
onGalleryItemClick = { event, index ->
val hideKeyboard = onGalleryEventItemClick(
state.timelineState.isLive,
event,
index,
)
if (hideKeyboard) {
localView.hideKeyboard()
}
},
onMessageLongClick = ::onMessageLongClick,
onUserDataClick = {
hidingKeyboard {
@@ -266,8 +306,8 @@ fun MessagesView(
onSwipeToReply = { targetEvent ->
state.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Reply, targetEvent))
},
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
onJoinCallClick = onJoinCallClick,
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick,
knockRequestsBannerView = knockRequestsBannerView,
)
@@ -375,12 +415,7 @@ fun MessagesView(
},
)
CustomReactionBottomSheet(
state = state.customReactionState,
onSelectEmoji = { uniqueId, emoji ->
state.eventSink(MessagesEvent.ToggleReaction(emoji.unicode, uniqueId))
}
)
customReactionBottomSheet()
ReactionSummaryView(state = state.reactionSummaryState)
ReadReceiptBottomSheet(
@@ -396,6 +431,28 @@ fun MessagesView(
)
}
@Composable
internal fun RowScope.MessagesMenuActions(
displayThreads: Boolean,
roomCallState: RoomCallState,
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
onThreadsListClick: () -> Unit,
) {
if (displayThreads) {
Icon(
modifier = Modifier.clickable(enabled = true, onClick = onThreadsListClick),
imageVector = CompoundIcons.ThreadsSolid(),
contentDescription = stringResource(CommonStrings.common_threads),
)
Spacer(Modifier.width(8.dp))
}
CallMenuItem(
roomCallState = roomCallState,
onJoinCallClick = onJoinCallClick,
)
Spacer(Modifier.width(8.dp))
}
@Composable
private fun ReinviteDialog(state: MessagesState) {
if (state.showReinvitePrompt) {
@@ -421,10 +478,11 @@ private fun MessagesViewContent(
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
onMessageLongClick: (TimelineItem.Event) -> Unit,
onGalleryItemClick: ((TimelineItem.Event, Int) -> Unit),
onSendLocationClick: () -> Unit,
onCreatePollClick: () -> Unit,
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
onViewAllPinnedMessagesClick: () -> Unit,
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
forceJumpToBottomVisibility: Boolean,
onSwipeToReply: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier,
@@ -464,42 +522,60 @@ private fun MessagesViewContent(
val scrollBehavior = PinnedMessagesBannerViewDefaults.rememberScrollBehavior(
pinnedMessagesCount = (state.pinnedMessagesBannerState as? PinnedMessagesBannerState.Visible)?.pinnedMessagesCount() ?: 0,
)
val density = LocalDensity.current
// Combined height of the banners overlaid above the timeline. Drives the floating
// date badge offset so the badge sits below whichever banners are currently showing.
var topBannersHeightDp by remember { mutableStateOf(0.dp) }
TimelineView(
state = state.timelineState,
timelineProtectionState = state.timelineProtectionState,
onUserDataClick = onUserDataClick,
onLinkClick = { link -> onLinkClick(link, false) },
onContentClick = onContentClick,
onGalleryItemClick = onGalleryItemClick,
onMessageLongClick = onMessageLongClick,
onSwipeToReply = onSwipeToReply,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
onJoinCallClick = onJoinCallClick,
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
nestedScrollConnection = scrollBehavior.nestedScrollConnection,
floatingDateTopOffset = topBannersHeightDp,
)
if (state.timelineState.timelineMode !is Timeline.Mode.Thread) {
AnimatedVisibility(
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
enter = expandVertically(),
exit = shrinkVertically(),
Column(
modifier = Modifier.onSizeChanged { topBannersHeightDp = with(density) { it.height.toDp() } },
) {
fun focusOnPinnedEvent(eventId: EventId) {
state.timelineState.eventSink(
TimelineEvent.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds)
AnimatedVisibility(
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
enter = expandVertically(),
exit = shrinkVertically(),
) {
fun focusOnPinnedEvent(eventId: EventId) {
state.timelineState.eventSink(
TimelineEvent.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds)
)
}
PinnedMessagesBannerView(
state = state.pinnedMessagesBannerState,
onClick = ::focusOnPinnedEvent,
onViewAllClick = onViewAllPinnedMessagesClick,
)
}
if (state.showLiveLocationShareBanner) {
LiveLocationSharingBanner(
onClick = { state.eventSink(MessagesEvent.ShowLiveLocationShare) },
onStopClick = { state.eventSink(MessagesEvent.StopLiveLocationShare) }
)
}
PinnedMessagesBannerView(
state = state.pinnedMessagesBannerState,
onClick = ::focusOnPinnedEvent,
onViewAllClick = onViewAllPinnedMessagesClick,
)
}
knockRequestsBannerView()
}
knockRequestsBannerView()
}
}
}
@@ -510,12 +586,17 @@ private fun MessagesViewComposerBottomSheetContents(
onRoomSuccessorClick: (RoomId) -> Unit,
onLinkClick: (String, Boolean) -> Unit,
) {
val contentPadding = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal).asPaddingValues()
when {
state.successorRoom != null -> {
SuccessorRoomBanner(roomSuccessor = state.successorRoom, onRoomSuccessorClick = onRoomSuccessorClick)
SuccessorRoomBanner(
modifier = Modifier.fillMaxWidth().padding(contentPadding),
roomSuccessor = state.successorRoom,
onRoomSuccessorClick = onRoomSuccessorClick
)
}
state.userEventPermissions.canSendMessage -> {
Column(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth().padding(contentPadding)) {
// Do not show the identity change if user is composing a Rich message or is seeing suggestion(s).
if (state.composerState.suggestions.isEmpty() &&
state.composerState.textEditorState is TextEditorState.Markdown) {
@@ -539,15 +620,15 @@ private fun MessagesViewComposerBottomSheetContents(
}
}
else -> {
CantSendMessageBanner()
CantSendMessageBanner(Modifier.padding(contentPadding))
}
}
}
@Composable
private fun CantSendMessageBanner() {
private fun CantSendMessageBanner(modifier: Modifier = Modifier) {
Row(
modifier = Modifier
modifier = modifier
.fillMaxWidth()
.background(ElementTheme.colors.bgSubtleSecondary)
.padding(16.dp),
@@ -587,6 +668,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
onBackClick = {},
onRoomDetailsClick = {},
onEventContentClick = { _, _ -> false },
onGalleryEventItemClick = { _, _, _ -> false },
onUserDataClick = {},
onLinkClick = { _, _ -> },
onSendLocationClick = {},
@@ -595,6 +677,8 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
onViewAllPinnedMessagesClick = { },
forceJumpToBottomVisibility = true,
knockRequestsBannerView = {},
customReactionBottomSheet = {},
onThreadsListClick = {},
)
}
@@ -641,13 +725,16 @@ internal fun MessagesViewA11yPreview() = ElementPreview {
onBackClick = {},
onRoomDetailsClick = {},
onEventContentClick = { _, _ -> false },
onGalleryEventItemClick = { _, _, _ -> false },
onUserDataClick = {},
onLinkClick = { _, _ -> },
onSendLocationClick = {},
onCreatePollClick = {},
onJoinCallClick = {},
onViewAllPinnedMessagesClick = { },
onViewAllPinnedMessagesClick = {},
onThreadsListClick = {},
forceJumpToBottomVisibility = true,
knockRequestsBannerView = {},
customReactionBottomSheet = {},
)
}
@@ -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,17 +39,18 @@ 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
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.emoji.api.recentemojis.GetRecentEmojis
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.room.BaseRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.recentemojis.api.GetRecentEmojis
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@@ -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) {
@@ -15,6 +15,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.anUnsignedDeviceSendFailure
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.event.aStaticLocationMode
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
@@ -127,7 +128,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
anActionListState(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(
content = aTimelineItemLocationContent(),
content = aTimelineItemLocationContent(mode = aStaticLocationMode()),
timelineItemReactions = reactionsState
),
sentTimeFull = "January 1, 1970 at 12:00 AM",
@@ -140,7 +141,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
anActionListState(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(
content = aTimelineItemLocationContent(),
content = aTimelineItemLocationContent(mode = aStaticLocationMode()),
timelineItemReactions = reactionsState
),
sentTimeFull = "January 1, 1970 at 12:00 AM",
@@ -31,7 +31,8 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
@@ -63,9 +64,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
@@ -109,7 +112,9 @@ fun ActionListView(
onVerifiedUserSendFailureClick: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier,
) {
val sheetState = rememberModalBottomSheetState()
val sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
)
val coroutineScope = rememberCoroutineScope()
val targetItem = (state.target as? ActionListState.Target.Success)?.event
@@ -156,6 +161,7 @@ fun ActionListView(
sheetState = sheetState,
onDismissRequest = ::onDismiss,
modifier = modifier,
scrollable = false,
) {
ActionListViewContent(
state = state,
@@ -242,13 +248,13 @@ private fun ActionListViewContent(
modifier = Modifier.clickable {
onActionClick(action)
},
headlineContent = {
content = {
Text(text = stringResource(id = action.titleRes))
},
leadingContent = ListItemContent.Icon(IconSource.Resource(action.icon)),
style = when {
action.destructive -> ListItemStyle.Destructive
else -> ListItemStyle.Primary
else -> ListItemStyle.Default
}
)
}
@@ -289,11 +295,21 @@ private fun MessageSummary(
is TimelineItemRedactedContent,
is TimelineItemUnknownContent -> content = { ContentForBody(textContent) }
is TimelineItemLocationContent -> {
content = { ContentForBody(stringResource(CommonStrings.common_shared_location)) }
val body = when (event.content.mode) {
is TimelineItemLocationContent.Mode.Live -> stringResource(CommonStrings.common_shared_live_location)
is TimelineItemLocationContent.Mode.Static -> stringResource(CommonStrings.common_shared_location)
}
content = { ContentForBody(body) }
}
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) }
}
@@ -396,7 +412,9 @@ private fun EmojiReactionsRow(
}
Box(
modifier = Modifier.padding(end = 10.dp).requiredSize(48.dp),
modifier = Modifier
.padding(end = 10.dp)
.requiredSize(48.dp),
contentAlignment = Alignment.CenterEnd,
) {
Icon(
@@ -443,7 +461,7 @@ private fun VerifiedUserSendFailureView(
.padding(horizontal = 16.dp, vertical = 8.dp),
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ErrorSolid())),
trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChevronRight())),
headlineContent = {
content = {
Text(
text = sendFailure.headline(),
style = ElementTheme.typography.fontBodySmMedium,
@@ -16,5 +16,12 @@ import kotlinx.parcelize.Parcelize
@Immutable
sealed interface Attachment : Parcelable {
@Parcelize
data class Media(val localMedia: LocalMedia) : Attachment
data class Media(
val localMedia: LocalMedia,
// When true, the media was picked through the "Files" picker and should be
// uploaded without image recompression; videos still use the highest available
// / best-fit preset rather than an additional size-reduction optimization pass.
// See https://github.com/element-hq/element-x-android/issues/6365
val sendAsFile: Boolean = false,
) : Attachment
}
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.attachments.preview
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAnimatedImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.mediaviewer.api.MediaInfo
import java.util.Locale
internal fun MediaInfo.canEditImage(): Boolean {
val resolvedMimeType = resolvedImageMimeType() ?: return false
return resolvedMimeType.isMimeTypeImage() &&
!resolvedMimeType.isMimeTypeAnimatedImage() &&
resolvedMimeType != MimeTypes.Svg
}
internal fun MediaInfo.isImageAttachment(): Boolean {
return resolvedImageMimeType().isMimeTypeImage()
}
internal fun MediaInfo.resolvedImageMimeType(): String? {
return mimeType.takeIf { it.isMimeTypeImage() } ?: fileExtension.toImageMimeTypeOrNull()
}
private fun String.toImageMimeTypeOrNull(): String? {
return when (lowercase(Locale.ROOT)) {
"png" -> MimeTypes.Png
"jpg", "jpeg" -> MimeTypes.Jpeg
"gif" -> MimeTypes.Gif
"webp" -> MimeTypes.WebP
"svg" -> MimeTypes.Svg
"bmp" -> "image/bmp"
"heic" -> "image/heic"
"heif" -> "image/heif"
"avif" -> "image/avif"
else -> null
}
}
@@ -8,8 +8,20 @@
package io.element.android.features.messages.impl.attachments.preview
import io.element.android.features.messages.impl.attachments.preview.imageeditor.NormalizedCropRect
sealed interface AttachmentsPreviewEvent {
data object SendAttachment : AttachmentsPreviewEvent
data object CancelAndDismiss : AttachmentsPreviewEvent
data object CancelAndClearSendState : AttachmentsPreviewEvent
data object OpenImageEditor : AttachmentsPreviewEvent
data object CloseImageEditor : AttachmentsPreviewEvent
data object RotateImageToTheLeft : AttachmentsPreviewEvent
data object FlipImageHorizontally : AttachmentsPreviewEvent
data object FlipImageVertically : AttachmentsPreviewEvent
data object ApplyImageEdits : AttachmentsPreviewEvent
data object ResetImageEdits : AttachmentsPreviewEvent
data class UpdateImageCropRect(val cropRect: NormalizedCropRect) : AttachmentsPreviewEvent
data object ClearImageEditError : AttachmentsPreviewEvent
data class SetCurrentCarouselIndex(val index: Int) : AttachmentsPreviewEvent
}
@@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
import kotlinx.collections.immutable.ImmutableList
@ContributesNode(RoomScope::class)
@AssistedInject
@@ -42,7 +43,7 @@ class AttachmentsPreviewNode(
private val enterpriseService: EnterpriseService,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val attachment: Attachment,
val attachments: ImmutableList<Attachment>,
val timelineMode: Timeline.Mode,
val inReplyToEventId: EventId?,
) : NodeInputs
@@ -54,7 +55,7 @@ class AttachmentsPreviewNode(
}
private val presenter = presenterFactory.create(
attachment = inputs.attachment,
attachments = inputs.attachments,
timelineMode = inputs.timelineMode,
onDoneListener = onDoneListener,
inReplyToEventId = inputs.inReplyToEventId,
@@ -11,7 +11,9 @@ package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -22,7 +24,12 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEditor
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
@@ -30,7 +37,6 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.firstInstanceOf
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.core.EventId
@@ -44,23 +50,28 @@ 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
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?,
mediaSenderFactory: MediaSenderFactory,
private val permalinkBuilder: PermalinkBuilder,
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,
@@ -68,13 +79,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
@@ -84,6 +100,11 @@ class AttachmentsPreviewPresenter(
val sendActionState = remember {
mutableStateOf<SendActionState>(SendActionState.Idle)
}
var canEditImage by remember { mutableStateOf(false) }
var imageEditorState by remember { mutableStateOf<AttachmentImageEditorState?>(null) }
var isApplyingImageEdits by remember { mutableStateOf(false) }
var displayImageEditError by remember { mutableStateOf(false) }
var editedTempFiles by remember { mutableStateOf<Map<Int, File>>(emptyMap()) }
val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false)
val textEditorState by rememberUpdatedState(
@@ -92,51 +113,115 @@ class AttachmentsPreviewPresenter(
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
var currentIndex by remember { mutableIntStateOf(0) }
var attachmentsAndEdits by remember {
mutableStateOf(
attachments.map {
AttachmentAndEdits(it, AttachmentImageEdits())
}
)
}
val editedAttachments by remember {
derivedStateOf {
attachmentsAndEdits.map { it.attachment }.toImmutableList()
}
}
var preprocessMediaJob by remember { mutableStateOf<Job?>(null) }
val mediaAttachment = attachment as Attachment.Media
val mediaOptimizationSelectorPresenter = remember {
mediaOptimizationSelectorPresenterFactory.create(mediaAttachment.localMedia)
val mediaOptimizationSelectorPresenters = remember {
attachments
.filterIsInstance<Attachment.Media>()
.mapIndexed { index, attachment ->
mediaOptimizationSelectorPresenterFactory.create(
index = index,
localMedia = attachment.localMedia,
sendAsFile = attachment.sendAsFile,
)
}
}
val mediaOptimizationSelectorState by rememberUpdatedState(mediaOptimizationSelectorPresenter.present())
val mediaOptimizationSelectorStates by rememberUpdatedState(
mediaOptimizationSelectorPresenters.map {
it.present()
}.toImmutableList()
)
val observableSendState = snapshotFlow { sendActionState.value }
var displayFileTooLargeError by remember { mutableStateOf(false) }
LaunchedEffect(mediaOptimizationSelectorState.displayMediaSelectorViews) {
LaunchedEffect(
mediaOptimizationSelectorStates,
imageEditorState,
isApplyingImageEdits,
editedAttachments,
) {
if (mediaOptimizationSelectorStates.any { it.displayMediaSelectorViews == true } ||
imageEditorState != null ||
isApplyingImageEdits
) {
// If any of the media optimization selectors are displayed, we don't want to pre-process the media yet
return@LaunchedEffect
}
// If the media optimization selector is not displayed, we can pre-process the media
// to prepare it for sending. This is done to avoid blocking the UI thread when the
// user clicks on the send button.
if (mediaOptimizationSelectorState.displayMediaSelectorViews == false) {
preprocessMediaJob = preProcessAttachment(
attachment = attachment,
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
val configs = mediaOptimizationSelectorStates.mapIndexed { index, mediaOptimizationSelectorState ->
getAutoPreprocessMediaOptimizationConfig(
mediaAttachment = editedAttachments[index] as Attachment.Media,
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
)
}
preprocessMediaJob?.cancel()
preprocessMediaJob = coroutineScope.launch(dispatchers.io) {
preProcessAttachments(
attachments = editedAttachments,
mediaOptimizationConfigs = configs,
displayProgress = false,
sendActionState = sendActionState,
)
}
}
val maxUploadSize = mediaOptimizationSelectorState.maxUploadSize.dataOrNull()
LaunchedEffect(currentIndex) {
val currentMedia = (attachments.getOrNull(currentIndex) as? Attachment.Media)?.localMedia
if (currentMedia != null) {
canEditImage = currentMedia.info.canEditImage() || attachmentImageEditor.canEdit(currentMedia)
}
}
val maxUploadSize = mediaOptimizationSelectorStates.firstNotNullOfOrNull {
it.maxUploadSize.dataOrNull()
}
LaunchedEffect(maxUploadSize) {
// Check file upload size if the media won't be processed for upload
val isImageFile = mediaAttachment.localMedia.info.mimeType.isMimeTypeImage()
val isVideoFile = mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo()
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
}
}
}
}
}
}
val videoSizeEstimations = mediaOptimizationSelectorState.videoSizeEstimations.dataOrNull()
LaunchedEffect(videoSizeEstimations) {
if (videoSizeEstimations != null) {
// Check if the video size estimations are too large for the max upload size
displayFileTooLargeError = videoSizeEstimations.none { it.canUpload }
mediaOptimizationSelectorStates.forEach { mediaOptimizationSelectorState ->
val videoSizeEstimations = mediaOptimizationSelectorState.videoSizeEstimations.dataOrNull()
LaunchedEffect(videoSizeEstimations) {
if (videoSizeEstimations != null) {
// Check if the video size estimations are too large for the max upload size
displayFileTooLargeError = videoSizeEstimations.none { it.canUpload }
}
}
}
@@ -144,19 +229,23 @@ class AttachmentsPreviewPresenter(
when (event) {
is AttachmentsPreviewEvent.SendAttachment -> {
ongoingSendAttachmentJob.value = coroutineScope.launch {
// If the media optimization selector is displayed, we need to wait for the user to select the options
// before we can pre-process the media.
if (mediaOptimizationSelectorState.displayMediaSelectorViews == true) {
val config = MediaOptimizationConfig(
compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled == true,
videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: VideoCompressionPreset.STANDARD,
)
preprocessMediaJob = preProcessAttachment(
attachment = attachment,
mediaOptimizationConfig = config,
displayProgress = true,
sendActionState = sendActionState,
)
if (preprocessMediaJob?.isActive != true && sendActionState.value !is SendActionState.Sending.ReadyToUpload) {
val configs = mediaOptimizationSelectorStates.map {
MediaOptimizationConfig(
compressImages = it.isImageOptimizationEnabled
?: mediaOptimizationConfigProvider.get().compressImages,
videoCompressionPreset = it.selectedVideoPreset
?: mediaOptimizationConfigProvider.get().videoCompressionPreset,
)
}
preprocessMediaJob = coroutineScope.launch(dispatchers.io) {
preProcessAttachments(
attachments = editedAttachments,
mediaOptimizationConfigs = configs,
displayProgress = true,
sendActionState = sendActionState,
)
}
}
// If the processing was hidden before, make it visible now
@@ -165,45 +254,47 @@ 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() }
// 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) {
sendPreProcessedMedia(
mediaUploadInfo = mediaUploadInfo,
sendMedia(
mediaUploadInfos = allMediaUploadInfos,
caption = caption,
sendActionState = sendActionState,
dismissAfterSend = false,
inReplyToEventId = inReplyToEventId,
)
// Clean up the pre-processed media after it's been sent
mediaSender.cleanUp()
editedTempFilesToDelete.values.forEach { it.safeDelete() }
}
}
}
AttachmentsPreviewEvent.CancelAndDismiss -> {
displayFileTooLargeError = false
displayImageEditError = false
isApplyingImageEdits = false
// Cancel media preprocessing and sending
preprocessMediaJob?.cancel()
preprocessMediaJob = null
// If we couldn't send the pre-processed media, remove it
mediaSender.cleanUp()
ongoingSendAttachmentJob.value?.cancel()
// Dismiss the screen
dismiss(
attachment,
sendActionState,
attachments = editedAttachments,
sendActionState = sendActionState,
editedTempFiles = editedTempFiles,
)
}
AttachmentsPreviewEvent.CancelAndClearSendState -> {
@@ -213,85 +304,207 @@ 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 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 = currentLocalMedia,
edits = attachmentsAndEdits.get(currentIndex).edits,
previewDebug = false,
)
}
}
AttachmentsPreviewEvent.CloseImageEditor -> {
imageEditorState = null
}
is AttachmentsPreviewEvent.UpdateImageCropRect -> {
val pendingState = imageEditorState ?: return
imageEditorState = pendingState.copy(
edits = pendingState.edits.copy(cropRect = event.cropRect)
)
}
AttachmentsPreviewEvent.RotateImageToTheLeft -> {
val pendingState = imageEditorState ?: return
imageEditorState = pendingState.copy(
edits = pendingState.edits.rotateAntiClockwise()
)
}
AttachmentsPreviewEvent.FlipImageHorizontally -> {
val pendingState = imageEditorState ?: return
imageEditorState = pendingState.copy(
edits = pendingState.edits.flipHorizontally()
)
}
AttachmentsPreviewEvent.FlipImageVertically -> {
val pendingState = imageEditorState ?: return
imageEditorState = pendingState.copy(
edits = pendingState.edits.flipVertically()
)
}
AttachmentsPreviewEvent.ResetImageEdits -> {
imageEditorState = imageEditorState?.copy(
edits = AttachmentImageEdits()
)
}
AttachmentsPreviewEvent.ApplyImageEdits -> {
val pendingState = imageEditorState ?: return
if (!pendingState.edits.hasChanges) {
editedTempFiles[currentIndex]?.safeDelete()
editedTempFiles = editedTempFiles - currentIndex
val currentAttachment = attachmentsAndEdits[currentIndex].attachment
attachmentsAndEdits = attachmentsAndEdits.toMutableList().also {
it[currentIndex] = AttachmentAndEdits(
currentAttachment,
pendingState.edits,
)
}.toImmutableList()
imageEditorState = null
resetPreparedMedia(sendActionState)
return
}
isApplyingImageEdits = true
displayImageEditError = false
coroutineScope.launch {
val result = withContext(dispatchers.io) {
attachmentImageEditor.exportEdits(
localMedia = pendingState.localMedia,
edits = pendingState.edits,
)
}
result.fold(
onSuccess = { editedMedia ->
editedTempFiles[currentIndex]?.safeDelete()
editedTempFiles = editedTempFiles + (currentIndex to editedMedia.file)
val currentAttachment = Attachment.Media(editedMedia.localMedia)
attachmentsAndEdits = attachmentsAndEdits.toMutableList().also {
it[currentIndex] = AttachmentAndEdits(
currentAttachment,
pendingState.edits,
)
}.toImmutableList()
imageEditorState = null
resetPreparedMedia(sendActionState)
},
onFailure = {
Timber.e(it, "Failed to apply image edits")
displayImageEditError = true
}
)
isApplyingImageEdits = false
}
}
AttachmentsPreviewEvent.ClearImageEditError -> {
displayImageEditError = false
}
is AttachmentsPreviewEvent.SetCurrentCarouselIndex -> {
currentIndex = event.index
}
}
}
return AttachmentsPreviewState(
attachment = attachment,
attachments = editedAttachments,
imageEditorState = imageEditorState,
canEditImage = canEditImage,
isApplyingImageEdits = isApplyingImageEdits,
displayImageEditError = displayImageEditError,
sendActionState = sendActionState.value,
textEditorState = textEditorState,
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
mediaOptimizationSelectorState = mediaOptimizationSelectorStates[currentIndex],
displayFileTooLargeError = displayFileTooLargeError,
currentIndex = currentIndex,
eventSink = ::handleEvent,
)
}
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 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 {
MediaOptimizationConfig(
compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled
?: mediaOptimizationConfigProvider.get().compressImages,
videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset
?: mediaOptimizationConfigProvider.get().videoCompressionPreset,
)
}
}
private suspend fun preProcessMedia(
mediaAttachment: Attachment.Media,
mediaOptimizationConfig: MediaOptimizationConfig,
private suspend fun preProcessAttachments(
attachments: List<Attachment>,
mediaOptimizationConfigs: List<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(
attachment: Attachment,
sendActionState: MutableState<SendActionState>,
) {
// 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>()
attachments.forEachIndexed { index, attachment ->
when (attachment) {
is Attachment.Media -> {
mediaSender.preProcessMedia(
uri = attachment.localMedia.uri,
mimeType = attachment.localMedia.info.mimeType,
mediaOptimizationConfig = mediaOptimizationConfigs[index],
).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
}
}
)
}
}
}
// 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()
}
@@ -304,36 +517,46 @@ class AttachmentsPreviewPresenter(
}
}
private suspend fun sendPreProcessedMedia(
mediaUploadInfo: MediaUploadInfo,
private fun resetPreparedMedia(sendActionState: MutableState<SendActionState>) {
sendActionState.value.mediaUploadInfoList()?.forEach(::cleanUp)
mediaSender.cleanUp()
sendActionState.value = SendActionState.Idle
}
private suspend fun sendMedia(
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)
}
}
)
@@ -10,18 +10,28 @@ package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEditorState
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,
val displayImageEditError: Boolean,
val sendActionState: SendActionState,
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 {
@@ -30,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
}
}
@@ -11,6 +11,8 @@ package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.core.net.toUri
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEditorState
import io.element.android.features.messages.impl.attachments.preview.imageeditor.anAttachmentImageEditorState
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation
import io.element.android.libraries.architecture.AsyncData
@@ -39,9 +41,12 @@ 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(),
),
anAttachmentsPreviewState(displayFileTooLargeError = true),
anAttachmentsPreviewState(
mediaInfo = aVideoMediaInfo(),
@@ -64,16 +69,55 @@ fun anAttachmentsPreviewState(
mediaInfo: MediaInfo = anImageMediaInfo(),
textEditorState: TextEditorState = aTextEditorStateMarkdown(),
sendActionState: SendActionState = SendActionState.Idle,
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,
isApplyingImageEdits = false,
displayImageEditError = false,
sendActionState = sendActionState,
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 = {}
)
@@ -102,6 +146,7 @@ fun aMediaOptimisationSelectorState(
displayMediaSelectorViews: Boolean = true,
displayVideoPresetSelectorDialog: Boolean = false,
) = MediaOptimizationSelectorState(
index = 0,
maxUploadSize = AsyncData.Success(maxUploadSize),
videoSizeEstimations = videoSizeEstimations,
isImageOptimizationEnabled = isImageOptimizationEnabled,
@@ -9,11 +9,15 @@
package io.element.android.features.messages.impl.attachments.preview
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
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.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,16 +25,22 @@ 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
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@@ -39,11 +49,11 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEditorView
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorEvent
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.ProgressDialogType
@@ -56,13 +66,15 @@ import io.element.android.libraries.designsystem.modifiers.niceClickable
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
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.utils.CommonDrawables
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.designsystem.theme.floatingDateBadgeBackground
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.textcomposer.TextComposer
@@ -73,7 +85,13 @@ 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
/**
* Ref: https://www.figma.com/design/zftpgS6LjiczobJZ1GUNpt/Updates-to-Media---File-Upload?node-id=51-3514
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AttachmentsPreviewView(
@@ -81,6 +99,13 @@ fun AttachmentsPreviewView(
localMediaRenderer: LocalMediaRenderer,
modifier: Modifier = Modifier,
) {
val canShowEditAction = when (state.sendActionState) {
is SendActionState.Sending.Uploading -> false
is SendActionState.Sending.Processing -> !state.sendActionState.displayProgress
SendActionState.Done -> false
else -> true
}
fun postSendAttachment() {
state.eventSink(AttachmentsPreviewEvent.SendAttachment)
}
@@ -93,33 +118,90 @@ fun AttachmentsPreviewView(
state.eventSink(AttachmentsPreviewEvent.CancelAndClearSendState)
}
BackHandler(enabled = state.sendActionState !is SendActionState.Sending.Uploading && state.sendActionState !is SendActionState.Done) {
postCancel()
fun postOpenImageEditor() {
state.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
}
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
navigationIcon = {
BackButton(
imageVector = CompoundIcons.Close(),
onClick = ::postCancel,
)
},
title = {},
fun postCloseImageEditor() {
state.eventSink(AttachmentsPreviewEvent.CloseImageEditor)
}
fun postResetImageEditor() {
state.eventSink(AttachmentsPreviewEvent.ResetImageEdits)
}
fun postApplyImageEdits() {
state.eventSink(AttachmentsPreviewEvent.ApplyImageEdits)
}
BackHandler(enabled = state.sendActionState !is SendActionState.Sending.Uploading && state.sendActionState !is SendActionState.Done) {
if (state.imageEditorState != null) {
postCloseImageEditor()
} else {
postCancel()
}
}
if (state.imageEditorState != null) {
AttachmentImageEditorView(
state = state.imageEditorState,
onCropRectChange = { cropRect ->
state.eventSink(AttachmentsPreviewEvent.UpdateImageCropRect(cropRect))
},
onRotateClick = { state.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft) },
onFlipHorizontallyClick = { state.eventSink(AttachmentsPreviewEvent.FlipImageHorizontally) },
onFlipVerticallyClick = { state.eventSink(AttachmentsPreviewEvent.FlipImageVertically) },
onCancelClick = ::postCloseImageEditor,
onResetClick = ::postResetImageEditor,
onDoneClick = ::postApplyImageEdits,
modifier = modifier,
)
} else {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
navigationIcon = {
BackButton(
onClick = ::postCancel,
)
},
title = {
Text(
modifier = Modifier.semantics {
heading()
},
text = stringResource(R.string.screen_media_upload_preview_title),
)
},
actions = {
if (state.canEditImage && canShowEditAction) {
IconButton(
onClick = ::postOpenImageEditor,
) {
Icon(
imageVector = CompoundIcons.Crop(),
contentDescription = stringResource(CommonStrings.action_edit),
)
}
}
}
)
}
) { paddingValues ->
AttachmentPreviewContent(
modifier = Modifier.padding(paddingValues),
state = state,
localMediaRenderer = localMediaRenderer,
onSendClick = ::postSendAttachment,
)
}
) { paddingValues ->
AttachmentPreviewContent(
modifier = Modifier.padding(paddingValues),
state = state,
localMediaRenderer = localMediaRenderer,
onSendClick = ::postSendAttachment,
)
}
AttachmentSendStateView(
sendActionState = state.sendActionState,
isApplyingImageEdits = state.isApplyingImageEdits,
displayImageEditError = state.displayImageEditError,
onDismissImageEditError = { state.eventSink(AttachmentsPreviewEvent.ClearImageEditError) },
onDismissClick = ::postClearSendState,
onRetryClick = ::postSendAttachment
)
@@ -128,39 +210,60 @@ fun AttachmentsPreviewView(
@Composable
private fun AttachmentSendStateView(
sendActionState: SendActionState,
isApplyingImageEdits: Boolean,
displayImageEditError: Boolean,
onDismissImageEditError: () -> Unit,
onDismissClick: () -> Unit,
onRetryClick: () -> Unit
) {
when (sendActionState) {
is SendActionState.Sending.Processing -> {
if (sendActionState.displayProgress) {
when {
isApplyingImageEdits -> {
ProgressDialog(
type = ProgressDialogType.Indeterminate,
text = stringResource(CommonStrings.common_preparing),
showCancelButton = false,
onDismissRequest = {},
)
}
displayImageEditError -> {
AlertDialog(
title = stringResource(CommonStrings.common_error),
content = stringResource(CommonStrings.common_something_went_wrong_message),
onDismiss = onDismissImageEditError,
)
}
else -> when (sendActionState) {
is SendActionState.Sending.Processing -> {
if (sendActionState.displayProgress) {
ProgressDialog(
type = ProgressDialogType.Indeterminate,
text = stringResource(CommonStrings.common_preparing),
showCancelButton = true,
onDismissRequest = onDismissClick,
)
}
}
is SendActionState.Sending.Uploading -> {
ProgressDialog(
type = ProgressDialogType.Indeterminate,
text = stringResource(CommonStrings.common_preparing),
text = stringResource(id = CommonStrings.common_sending),
showCancelButton = true,
onDismissRequest = onDismissClick,
)
}
is SendActionState.Failure -> {
RetryDialog(
content = stringResource(sendAttachmentError(sendActionState.error)),
onDismiss = onDismissClick,
onRetry = onRetryClick
)
}
else -> Unit
}
is SendActionState.Sending.Uploading -> {
ProgressDialog(
type = ProgressDialogType.Indeterminate,
text = stringResource(id = CommonStrings.common_sending),
showCancelButton = true,
onDismissRequest = onDismissClick,
)
}
is SendActionState.Failure -> {
RetryDialog(
content = stringResource(sendAttachmentError(sendActionState.error)),
onDismiss = onDismissClick,
onRetry = onRetryClick
)
}
else -> Unit
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
private fun AttachmentPreviewContent(
state: AttachmentsPreviewState,
@@ -176,18 +279,77 @@ private fun AttachmentPreviewContent(
Box(
modifier = Modifier
.weight(1f),
contentAlignment = Alignment.Center
contentAlignment = Alignment.Center,
) {
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 mimeType = (state.attachment as? Attachment.Media)?.localMedia?.info?.mimeType
if (mimeType?.isMimeTypeImage() == true) {
val mediaInfo = (state.attachments[state.currentIndex] as? Attachment.Media)?.localMedia?.info
if (mediaInfo?.isImageAttachment() == true) {
ImageOptimizationSelector(state.mediaOptimizationSelectorState)
} else if (mimeType?.isMimeTypeVideo() == true) {
} else if (mediaInfo?.mimeType?.isMimeTypeVideo() == true) {
VideoPresetSelector(state = state.mediaOptimizationSelectorState)
}
@@ -220,7 +382,8 @@ private fun AttachmentPreviewContent(
private fun ImageOptimizationSelector(state: MediaOptimizationSelectorState) {
if (state.displayMediaSelectorViews == true) {
Row(
modifier = Modifier.fillMaxWidth()
modifier = Modifier
.fillMaxWidth()
.niceClickable {
state.isImageOptimizationEnabled?.let { value ->
state.eventSink(MediaOptimizationSelectorEvent.SelectImageOptimization(!value))
@@ -229,7 +392,9 @@ private fun ImageOptimizationSelector(state: MediaOptimizationSelectorState) {
.padding(horizontal = 16.dp, vertical = 16.dp)
) {
Text(
modifier = Modifier.weight(1f).align(Alignment.CenterVertically),
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically),
text = stringResource(R.string.screen_media_upload_preview_optimize_image_quality_title),
style = ElementTheme.typography.fontBodyLgRegular,
)
@@ -255,7 +420,8 @@ private fun VideoPresetSelector(
if (state.displayMediaSelectorViews == true && videoPresets != null && state.selectedVideoPreset != null) {
Column(
modifier = Modifier.fillMaxWidth()
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp)
.niceClickable { state.eventSink(MediaOptimizationSelectorEvent.OpenVideoPresetSelectorDialog) }
) {
@@ -328,7 +494,7 @@ private fun VideoQualitySelectorDialog(
val estimationMb = sizeFormatter.format(videoEstimation.sizeInBytes, true)
val title = "${preset.title()} ($estimationMb)"
ListItem(
headlineContent = {
content = {
Text(
text = title,
style = ElementTheme.typography.fontBodyLgMedium,
@@ -390,16 +556,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 = SampleMediaRenderer(),
)
}
@Preview
@Composable
internal fun AttachmentsPreviewGalleryViewPreview() = ElementPreviewDark {
AttachmentsPreviewView(
state = anAttachmentsPreviewGalleryState(),
localMediaRenderer = SampleMediaRenderer(),
)
}
@@ -442,3 +608,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,
)
}
}
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
/**
* An implementation of [LocalMediaRenderer] that displays a sample background image.
* To be used for Previews only.
*/
internal class SampleMediaRenderer : LocalMediaRenderer {
@Composable
override fun Render(localMedia: LocalMedia) {
Image(
painter = painterResource(id = CommonDrawables.sample_background),
modifier = Modifier.fillMaxSize(),
contentDescription = null,
)
}
}
@@ -0,0 +1,202 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.attachments.preview.imageeditor
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.net.Uri
import androidx.exifinterface.media.ExifInterface
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.messages.impl.attachments.preview.resolvedImageMimeType
import io.element.android.libraries.androidutils.bitmap.rotateToExifMetadataOrientation
import io.element.android.libraries.androidutils.bitmap.writeBitmap
import io.element.android.libraries.androidutils.file.createTmpFile
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAnimatedImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import kotlinx.coroutines.withContext
import java.io.File
import kotlin.math.roundToInt
private const val EDITED_MEDIA_DIR_NAME = "edited-media"
interface AttachmentImageEditor {
suspend fun canEdit(localMedia: LocalMedia): Boolean
suspend fun exportEdits(
localMedia: LocalMedia,
edits: AttachmentImageEdits,
): Result<EditedLocalMedia>
}
data class EditedLocalMedia(
val localMedia: LocalMedia,
val file: File,
)
@ContributesBinding(AppScope::class)
class DefaultAttachmentImageEditor(
@ApplicationContext private val context: Context,
private val dispatchers: CoroutineDispatchers,
) : AttachmentImageEditor {
override suspend fun canEdit(localMedia: LocalMedia): Boolean = withContext(dispatchers.io) {
localMedia.info.resolvedImageMimeType()
?.takeIf { it.isEditableStillImageMimeType() }
?.let { return@withContext true }
val decodedMimeType = context.contentResolver.openInputStream(localMedia.uri)?.use { input ->
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeStream(input, null, options)
options.outMimeType
}
decodedMimeType.isEditableStillImageMimeType()
}
override suspend fun exportEdits(
localMedia: LocalMedia,
edits: AttachmentImageEdits,
): Result<EditedLocalMedia> = withContext(dispatchers.io) {
runCatchingExceptions {
val sourceMimeType = localMedia.info.resolvedImageMimeType() ?: localMedia.info.mimeType
val exportedMimeType = exportedMimeTypeFor(sourceMimeType)
val exifOrientation = context.contentResolver.openInputStream(localMedia.uri)?.let { input ->
input.use {
ExifInterface(it).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED)
}
} ?: ExifInterface.ORIENTATION_UNDEFINED
val decodedBitmap = context.contentResolver.openInputStream(localMedia.uri)?.use { input ->
BitmapFactory.decodeStream(input)
} ?: error("Unable to decode image from ${localMedia.uri}")
val normalizedBitmap = decodedBitmap.rotateToExifMetadataOrientation(exifOrientation)
if (normalizedBitmap !== decodedBitmap) {
decodedBitmap.recycle()
}
val transformedBitmap = normalizedBitmap.applyEdits(edits)
if (transformedBitmap !== normalizedBitmap) {
normalizedBitmap.recycle()
}
val cropRect = edits.cropRect.toPixelRect(
imageWidth = transformedBitmap.width,
imageHeight = transformedBitmap.height,
)
val isCropUnchanged = cropRect.left == 0 && cropRect.top == 0 &&
cropRect.width() == transformedBitmap.width && cropRect.height() == transformedBitmap.height
val croppedBitmap = if (isCropUnchanged) {
transformedBitmap
} else {
Bitmap.createBitmap(
transformedBitmap,
cropRect.left,
cropRect.top,
cropRect.width(),
cropRect.height(),
)
}
if (croppedBitmap !== transformedBitmap) {
transformedBitmap.recycle()
}
val editedMediaDir = File(context.cacheDir, EDITED_MEDIA_DIR_NAME).apply { mkdirs() }
val outputFile = context.createTmpFile(baseDir = editedMediaDir, extension = compressFileExtension(exportedMimeType))
outputFile.writeBitmap(
bitmap = croppedBitmap,
format = compressFormat(exportedMimeType),
quality = 90,
)
croppedBitmap.recycle()
EditedLocalMedia(
localMedia = localMedia.copy(
uri = Uri.fromFile(outputFile),
info = localMedia.info.copy(mimeType = exportedMimeType),
),
file = outputFile,
)
}
}
}
internal fun exportedMimeTypeFor(sourceMimeType: String?): String {
return if (sourceMimeType == MimeTypes.Png) {
MimeTypes.Png
} else {
MimeTypes.Jpeg
}
}
private fun Bitmap.applyEdits(edits: AttachmentImageEdits): Bitmap {
val normalizedTurns = (edits.rotationQuarterTurns % 4 + 4) % 4
if (normalizedTurns == 0 && !edits.isFlippedHorizontally && !edits.isFlippedVertically) {
return this
}
val centerX = width / 2f
val centerY = height / 2f
val matrix = Matrix().apply {
val scaleX = if (edits.isFlippedHorizontally) -1f else 1f
val scaleY = if (edits.isFlippedVertically) -1f else 1f
if (scaleX < 0f || scaleY < 0f) {
postScale(scaleX, scaleY, centerX, centerY)
}
if (normalizedTurns != 0) {
postRotate(normalizedTurns * 90f, centerX, centerY)
}
}
return Bitmap.createBitmap(this, 0, 0, width, height, matrix, true)
}
private data class PixelCropRect(
val left: Int,
val top: Int,
val right: Int,
val bottom: Int,
) {
fun width() = right - left
fun height() = bottom - top
}
private fun NormalizedCropRect.toPixelRect(imageWidth: Int, imageHeight: Int): PixelCropRect {
val leftPx = (left * imageWidth).roundToInt().coerceIn(0, imageWidth - 1)
val topPx = (top * imageHeight).roundToInt().coerceIn(0, imageHeight - 1)
val rightPx = (right * imageWidth).roundToInt().coerceIn(leftPx + 1, imageWidth)
val bottomPx = (bottom * imageHeight).roundToInt().coerceIn(topPx + 1, imageHeight)
return PixelCropRect(
left = leftPx,
top = topPx,
right = rightPx,
bottom = bottomPx,
)
}
private fun compressFormat(mimeType: String) = when (mimeType) {
MimeTypes.Png -> Bitmap.CompressFormat.PNG
else -> Bitmap.CompressFormat.JPEG
}
private fun compressFileExtension(mimeType: String) = when (mimeType) {
MimeTypes.Png -> "png"
else -> "jpeg"
}
private fun String?.isEditableStillImageMimeType(): Boolean {
return this != null &&
this.isMimeTypeImage() &&
!this.isMimeTypeAnimatedImage() &&
this != MimeTypes.Svg
}
@@ -0,0 +1,197 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.attachments.preview.imageeditor
import androidx.annotation.FloatRange
import androidx.compose.runtime.Immutable
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
private const val DEFAULT_CROP_MARGIN = 0f
private const val MIN_CROP_SIZE = 0.1f
@Immutable
data class AttachmentImageEditorState(
val localMedia: LocalMedia,
val edits: AttachmentImageEdits,
// For preview only
val previewDebug: Boolean,
)
@Immutable
data class AttachmentImageEdits(
val cropRect: NormalizedCropRect = NormalizedCropRect.default(),
val rotationQuarterTurns: Int = 0,
val isFlippedHorizontally: Boolean = false,
val isFlippedVertically: Boolean = false,
) {
val normalizedRotationQuarterTurns: Int
get() = rotationQuarterTurns % 4
val rotationDegrees: Int
get() = normalizedRotationQuarterTurns * 90
val hasChanges: Boolean
get() = cropRect != NormalizedCropRect.default() ||
normalizedRotationQuarterTurns != 0 ||
isFlippedHorizontally ||
isFlippedVertically
fun rotateAntiClockwise(): AttachmentImageEdits {
return copy(
rotationQuarterTurns = (normalizedRotationQuarterTurns + 3) % 4,
// Also update the crop rect to keep the same selected area
cropRect = cropRect.rotateAntiClockwise()
)
}
fun flipHorizontally(): AttachmentImageEdits {
return copy(
isFlippedHorizontally = !isFlippedHorizontally,
// Also update the crop rect to keep the same selected area
cropRect = cropRect.flipHorizontally(),
)
}
fun flipVertically(): AttachmentImageEdits {
return copy(
isFlippedVertically = !isFlippedVertically,
// Also update the crop rect to keep the same selected area
cropRect = cropRect.flipVertically(),
)
}
}
@Immutable
data class NormalizedCropRect(
@FloatRange(from = 0.0, to = 1.0) val left: Float,
@FloatRange(from = 0.0, to = 1.0) val top: Float,
@FloatRange(from = 0.0, to = 1.0) val right: Float,
@FloatRange(from = 0.0, to = 1.0) val bottom: Float,
) {
init {
require(left in 0f..1f)
require(top in 0f..1f)
require(right in 0f..1f)
require(bottom in 0f..1f)
require(left < right)
require(top < bottom)
}
val width: Float
get() = right - left
val height: Float
get() = bottom - top
fun applyChange(
dragTarget: CropDragTarget,
deltaX: Float,
deltaY: Float,
): NormalizedCropRect = when (dragTarget) {
is CropDragTarget.Move -> translate(deltaX, deltaY)
is CropDragTarget.Corner -> dragWithCorner(dragTarget, deltaX, deltaY)
is CropDragTarget.Edge -> dragWithEdge(dragTarget, deltaX, deltaY)
}
private fun translate(deltaX: Float, deltaY: Float): NormalizedCropRect {
val clampedLeft = (left + deltaX).coerceIn(0f, 1f - width)
val clampedTop = (top + deltaY).coerceIn(0f, 1f - height)
return copy(
left = clampedLeft,
top = clampedTop,
right = clampedLeft + width,
bottom = clampedTop + height,
)
}
private fun dragWithCorner(
dragTarget: CropDragTarget.Corner,
deltaX: Float,
deltaY: Float,
) = when (dragTarget) {
CropDragTarget.Corner.TopLeft -> copy(
left = (left + deltaX).coerceIn(0f, right - MIN_CROP_SIZE),
top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE),
)
CropDragTarget.Corner.TopRight -> copy(
right = (right + deltaX).coerceIn(left + MIN_CROP_SIZE, 1f),
top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE),
)
CropDragTarget.Corner.BottomRight -> copy(
right = (right + deltaX).coerceIn(left + MIN_CROP_SIZE, 1f),
bottom = (bottom + deltaY).coerceIn(top + MIN_CROP_SIZE, 1f),
)
CropDragTarget.Corner.BottomLeft -> copy(
left = (left + deltaX).coerceIn(0f, right - MIN_CROP_SIZE),
bottom = (bottom + deltaY).coerceIn(top + MIN_CROP_SIZE, 1f),
)
}
private fun dragWithEdge(
dragTarget: CropDragTarget.Edge,
deltaX: Float,
deltaY: Float,
) = when (dragTarget) {
CropDragTarget.Edge.Top -> copy(
top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE),
)
CropDragTarget.Edge.Right -> copy(
right = (right + deltaX).coerceIn(left + MIN_CROP_SIZE, 1f),
)
CropDragTarget.Edge.Bottom -> copy(
bottom = (bottom + deltaY).coerceIn(top + MIN_CROP_SIZE, 1f),
)
CropDragTarget.Edge.Left -> copy(
left = (left + deltaX).coerceIn(0f, right - MIN_CROP_SIZE),
)
}
fun rotateAntiClockwise() = copy(
left = top,
top = 1f - right,
right = bottom,
bottom = 1f - left,
)
fun flipHorizontally() = copy(
left = 1f - right,
right = 1f - left,
)
fun flipVertically() = copy(
top = 1f - bottom,
bottom = 1f - top,
)
companion object {
fun default() = NormalizedCropRect(
left = DEFAULT_CROP_MARGIN,
top = DEFAULT_CROP_MARGIN,
right = 1f - DEFAULT_CROP_MARGIN,
bottom = 1f - DEFAULT_CROP_MARGIN,
)
}
}
sealed interface CropDragTarget {
data object Move : CropDragTarget
sealed interface Corner : CropDragTarget {
data object TopLeft : Corner
data object TopRight : Corner
data object BottomRight : Corner
data object BottomLeft : Corner
}
sealed interface Edge : CropDragTarget {
data object Top : Edge
data object Right : Edge
data object Bottom : Edge
data object Left : Edge
}
}
@@ -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.features.messages.impl.attachments.preview.imageeditor
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.core.net.toUri
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
open class AttachmentImageEditorStateProvider : PreviewParameterProvider<AttachmentImageEditorState> {
private val caterpillarCrop = NormalizedCropRect(
left = 0.3f,
top = 0.3f,
right = 0.8f,
bottom = 0.75f,
)
override val values: Sequence<AttachmentImageEditorState>
get() = sequenceOf(
anAttachmentImageEditorState(
edits = AttachmentImageEdits(
// Cheat a bit so that the crop match the sample image size (1024 * 682)
cropRect = 0.17f.let { correction ->
NormalizedCropRect(
left = 0f,
top = correction,
right = 1f,
bottom = 1 - correction,
)
},
),
),
anAttachmentImageEditorState(
edits = AttachmentImageEdits(
cropRect = caterpillarCrop,
),
),
anAttachmentImageEditorState(
edits = AttachmentImageEdits(
cropRect = caterpillarCrop,
),
previewDebug = true,
),
anAttachmentImageEditorState(
edits = AttachmentImageEdits(
cropRect = caterpillarCrop,
).rotateAntiClockwise(),
),
// Small crop
anAttachmentImageEditorState(
edits = AttachmentImageEdits(
cropRect = NormalizedCropRect(
left = 0.3f,
top = 0.6f,
right = 0.4f,
bottom = 0.7f,
),
),
previewDebug = true,
),
// Big crop
anAttachmentImageEditorState(
edits = AttachmentImageEdits(
cropRect = NormalizedCropRect(
left = 0.05f,
top = 0.05f,
right = 0.95f,
bottom = 0.95f,
),
),
previewDebug = true,
),
anAttachmentImageEditorState(
edits = AttachmentImageEdits(
cropRect = caterpillarCrop,
).flipHorizontally(),
),
anAttachmentImageEditorState(
edits = AttachmentImageEdits(
cropRect = caterpillarCrop,
).flipVertically(),
),
)
}
internal fun anAttachmentImageEditorState(
localMedia: LocalMedia = LocalMedia(
uri = "file://preview-image".toUri(),
info = anImageMediaInfo(),
),
edits: AttachmentImageEdits = AttachmentImageEdits(),
previewDebug: Boolean = false,
) = AttachmentImageEditorState(
localMedia = localMedia,
edits = edits,
previewDebug = previewDebug,
)
@@ -0,0 +1,691 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.attachments.preview.imageeditor
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.boundsInParent
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.R
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.text.toPx
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.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.ui.strings.CommonStrings
import kotlin.math.min
private val minHandleTouchRadius = 16.dp
private val maxHandleTouchRadius = 56.dp
/**
* Ref: https://www.figma.com/design/zftpgS6LjiczobJZ1GUNpt/Updates-to-Media---File-Upload?node-id=51-3539
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AttachmentImageEditorView(
state: AttachmentImageEditorState,
onCropRectChange: (NormalizedCropRect) -> Unit,
onRotateClick: () -> Unit,
onFlipHorizontallyClick: () -> Unit,
onFlipVerticallyClick: () -> Unit,
onResetClick: () -> Unit,
onCancelClick: () -> Unit,
onDoneClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val rotateContentDescription = stringResource(R.string.screen_image_edition_a11y_rotate_to_the_left)
val rotationStateDescription = pluralStringResource(
R.plurals.screen_image_edition_a11y_rotation_state,
state.edits.rotationDegrees,
state.edits.rotationDegrees,
)
val flipHorizontalLabel = stringResource(R.string.screen_image_edition_a11y_flip_image_horizontally)
val flipHorizontalState = if (state.edits.isFlippedHorizontally) {
stringResource(R.string.screen_image_edition_a11y_flip_image_horizontally_state_flipped)
} else {
stringResource(R.string.screen_image_edition_a11y_flip_image_horizontally_state_original)
}
val flipVerticalLabel = stringResource(R.string.screen_image_edition_a11y_flip_image_vertically)
val flipVerticalState = if (state.edits.isFlippedVertically) {
stringResource(R.string.screen_image_edition_a11y_flip_image_vertically_state_flipped)
} else {
stringResource(R.string.screen_image_edition_a11y_flip_image_vertically_state_original)
}
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = {
TopAppBar(
navigationIcon = {
BackButton(
imageVector = CompoundIcons.Close(),
onClick = onCancelClick,
)
},
title = {
Text(
modifier = Modifier.semantics {
heading()
},
text = stringResource(R.string.screen_image_edition_title),
)
},
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.background(ElementTheme.colors.bgCanvasDefault)
.padding(paddingValues)
) {
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center,
) {
CropEditorCanvas(
state = state,
onCropRectChange = onCropRectChange,
)
}
Row(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.widthIn(max = 360.dp)
.navigationBarsPadding()
.padding(start = 20.dp, top = 18.dp, end = 20.dp, bottom = 18.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.CenterStart,
) {
TextButton(
text = stringResource(CommonStrings.action_reset),
destructive = true,
onClick = onResetClick,
)
}
Row(
modifier = Modifier.weight(2f),
// Center the content horizontally
horizontalArrangement = Arrangement.Center,
) {
IconButton(
onClick = onFlipHorizontallyClick,
modifier = Modifier
.clearAndSetSemantics {
contentDescription = flipHorizontalLabel
stateDescription = flipHorizontalState
}
) {
Icon(
imageVector = CompoundIcons.FlipHorizontal(),
contentDescription = null,
)
}
IconButton(
onClick = onRotateClick,
modifier = Modifier
.clearAndSetSemantics {
contentDescription = rotateContentDescription
stateDescription = rotationStateDescription
}
) {
Icon(
imageVector = CompoundIcons.RotateLeft(),
contentDescription = null,
)
}
IconButton(
onClick = onFlipVerticallyClick,
modifier = Modifier
.clearAndSetSemantics {
contentDescription = flipVerticalLabel
stateDescription = flipVerticalState
}
) {
Icon(
imageVector = CompoundIcons.FlipVertical(),
contentDescription = null,
)
}
}
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.CenterEnd,
) {
TextButton(
text = stringResource(CommonStrings.action_save),
onClick = onDoneClick,
)
}
}
}
}
}
@Composable
private fun BoxScope.CropEditorCanvas(
state: AttachmentImageEditorState,
onCropRectChange: (NormalizedCropRect) -> Unit,
) {
var imageSize by remember(state.localMedia.uri) { mutableStateOf(IntSize.Zero) }
val rotationQuarterTurns = state.edits.normalizedRotationQuarterTurns
val flipScaleX = if (state.edits.isFlippedHorizontally) -1f else 1f
val flipScaleY = if (state.edits.isFlippedVertically) -1f else 1f
var imageRect by remember { mutableStateOf(Rect.Zero) }
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.padding(20.dp),
) {
val displayedSize = remember(maxWidth, maxHeight, imageSize, rotationQuarterTurns) {
val sourceWidth = imageSize.width.takeIf { it > 0 } ?: 1
val sourceHeight = imageSize.height.takeIf { it > 0 } ?: 1
val aspectRatio = if (rotationQuarterTurns % 2 == 0) {
sourceWidth.toFloat() / sourceHeight.toFloat()
} else {
sourceHeight.toFloat() / sourceWidth.toFloat()
}
fitSize(
containerWidth = constraints.maxWidth.toFloat(),
containerHeight = constraints.maxHeight.toFloat(),
aspectRatio = aspectRatio,
)
}
val density = LocalDensity.current
val displayedWidthDp = with(density) { displayedSize.width.toDp() }
val displayedHeightDp = with(density) { displayedSize.height.toDp() }
val imageLayoutSize = remember(displayedSize, rotationQuarterTurns) {
if (rotationQuarterTurns % 2 == 0) {
displayedSize
} else {
Size(
width = displayedSize.height,
height = displayedSize.width,
)
}
}
val imageLayoutWidthDp = with(density) { imageLayoutSize.width.toDp() }
val imageLayoutHeightDp = with(density) { imageLayoutSize.height.toDp() }
Box(
modifier = Modifier
.size(displayedWidthDp, displayedHeightDp)
.align(Alignment.Center)
.onPlaced {
imageRect = it.boundsInParent()
},
contentAlignment = Alignment.Center,
) {
if (LocalInspectionMode.current) {
Image(
painter = painterResource(id = CommonDrawables.sample_background),
contentDescription = null,
modifier = Modifier
.requiredSize(imageLayoutWidthDp, imageLayoutHeightDp)
.graphicsLayer {
scaleX = flipScaleX
scaleY = flipScaleY
}
.graphicsLayer { rotationZ = rotationQuarterTurns * 90f },
contentScale = ContentScale.Fit,
)
} else {
AsyncImage(
model = state.localMedia.uri,
contentDescription = stringResource(CommonStrings.common_image),
modifier = Modifier
.requiredSize(imageLayoutWidthDp, imageLayoutHeightDp)
.graphicsLayer {
scaleX = flipScaleX
scaleY = flipScaleY
}
.graphicsLayer { rotationZ = rotationQuarterTurns * 90f },
contentScale = ContentScale.Fit,
onState = { painterState ->
if (painterState is AsyncImagePainter.State.Success) {
imageSize = IntSize(
width = painterState.result.image.width,
height = painterState.result.image.height,
)
}
}
)
}
}
val minHandleTouchRadiusPx = minHandleTouchRadius.toPx()
val maxHandleTouchRadiusPx = maxHandleTouchRadius.toPx()
val touchRadiusPx by rememberUpdatedState(
(min(
state.edits.cropRect.width * imageRect.width,
state.edits.cropRect.height * imageRect.height,
) / 4f).coerceIn(
minHandleTouchRadiusPx,
maxHandleTouchRadiusPx,
)
)
var dragTarget by remember { mutableStateOf<CropDragTarget?>(null) }
val latestCropRect by rememberUpdatedState(state.edits.cropRect)
val drawGuidelines = dragTarget == CropDragTarget.Move || state.previewDebug
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { offset ->
dragTarget = detectDragTarget(
touchPoint = offset,
imageOffset = imageRect.topLeft,
cropRect = latestCropRect,
canvasSize = Size(imageRect.width, imageRect.height),
handleTouchRadius = touchRadiusPx,
)
},
onDragCancel = {
dragTarget = null
},
onDragEnd = {
dragTarget = null
},
) { change, dragAmount ->
val activeTarget = dragTarget ?: return@detectDragGestures
change.consume()
val gestureAreaWidth = imageRect.width.takeIf { it > 0f } ?: size.width.toFloat()
val gestureAreaHeight = imageRect.height.takeIf { it > 0f } ?: size.height.toFloat()
onCropRectChange(
latestCropRect.applyChange(
dragTarget = activeTarget,
deltaX = dragAmount.x / gestureAreaWidth,
deltaY = dragAmount.y / gestureAreaHeight,
)
)
}
},
contentAlignment = Alignment.Center,
) {
CropOverlay(
imageSize = DpSize(displayedWidthDp, displayedHeightDp),
cropRect = state.edits.cropRect,
drawGuidelines = drawGuidelines,
previewDebug = state.previewDebug,
touchRadiusPx = touchRadiusPx,
dragTarget = dragTarget,
)
}
}
}
@Composable
private fun CropOverlay(
imageSize: DpSize,
cropRect: NormalizedCropRect,
drawGuidelines: Boolean,
previewDebug: Boolean,
touchRadiusPx: Float,
dragTarget: CropDragTarget?,
) {
val borderColor = ElementTheme.colors.iconPrimary
val guideColor = ElementTheme.colors.iconPrimary
Canvas(
modifier = Modifier.size(imageSize.width, imageSize.height)
) {
val cropLeft = cropRect.left * size.width
val cropTop = cropRect.top * size.height
val cropRight = cropRect.right * size.width
val cropBottom = cropRect.bottom * size.height
// Hardcoded black: the crop overlay must always darken the image regardless of theme.
// No semantic token exists for this use case in the Compound design system.
val overlayColor = Color.Black.copy(alpha = 0.48f)
// Overlay above the crop area
drawRect(
color = overlayColor,
topLeft = Offset.Zero,
size = Size(width = size.width, height = cropTop),
)
// Overlay on the left of the crop area
drawRect(
color = overlayColor,
topLeft = Offset(0f, cropTop),
size = Size(width = cropLeft, height = cropBottom - cropTop),
)
// Overlay on the right of the crop area
drawRect(
color = overlayColor,
topLeft = Offset(cropRight, cropTop),
size = Size(width = size.width - cropRight, height = cropBottom - cropTop),
)
// Overlay below the crop area
drawRect(
color = overlayColor,
topLeft = Offset(0f, cropBottom),
size = Size(width = size.width, height = size.height - cropBottom),
)
// Main frame of the crop area
drawRect(
color = borderColor,
topLeft = Offset(cropLeft, cropTop),
size = Size(width = cropRight - cropLeft, height = cropBottom - cropTop),
style = Stroke(width = 1.dp.toPx()),
)
// Guidelines dividing the crop area into 9 equal parts
if (drawGuidelines) {
val thirdWidth = (cropRight - cropLeft) / 3f
val thirdHeight = (cropBottom - cropTop) / 3f
for (index in 1..2) {
val offsetX = cropLeft + thirdWidth * index
val offsetY = cropTop + thirdHeight * index
// Vertical guide line
drawLine(
color = guideColor,
start = Offset(offsetX, cropTop),
end = Offset(offsetX, cropBottom),
strokeWidth = 1.dp.toPx(),
)
// Horizontal guide line
drawLine(
color = guideColor,
start = Offset(cropLeft, offsetY),
end = Offset(cropRight, offsetY),
strokeWidth = 1.dp.toPx(),
)
}
}
// Corner handles
val handleLength = 18.dp.toPx()
val handleOffset = 2.dp.toPx()
// Top left corner
drawCornerHandle(
x = cropLeft - handleOffset,
y = cropTop - handleOffset,
handleLength = handleLength,
color = borderColor,
position = CropDragTarget.Corner.TopLeft,
)
// Top right corner
drawCornerHandle(
x = cropRight + handleOffset,
y = cropTop - handleOffset,
handleLength = handleLength,
color = borderColor,
position = CropDragTarget.Corner.TopRight,
)
// Bottom left corner
drawCornerHandle(
x = cropLeft - handleOffset,
y = cropBottom + handleOffset,
handleLength = handleLength,
color = borderColor,
position = CropDragTarget.Corner.BottomLeft,
)
// Bottom right corner
drawCornerHandle(
x = cropRight + handleOffset,
y = cropBottom + handleOffset,
handleLength = handleLength,
color = borderColor,
position = CropDragTarget.Corner.BottomRight,
)
val handleColor = borderColor
// Top handle
drawEdgeHandle(
center = Offset((cropLeft + cropRight) / 2f, cropTop - handleOffset),
horizontal = true,
handleLength = handleLength,
color = handleColor,
)
// Right handle
drawEdgeHandle(
center = Offset(cropRight + handleOffset, (cropTop + cropBottom) / 2f),
horizontal = false,
handleLength = handleLength,
color = handleColor,
)
// Bottom handle
drawEdgeHandle(
center = Offset((cropLeft + cropRight) / 2f, cropBottom + handleOffset),
horizontal = true,
handleLength = handleLength,
color = handleColor,
)
// Left handle
drawEdgeHandle(
center = Offset(cropLeft - handleOffset, (cropTop + cropBottom) / 2f),
horizontal = false,
handleLength = handleLength,
color = handleColor,
)
if (previewDebug) {
// Draw disk around touchable area
listOf(
CropDragTarget.Edge.Top,
CropDragTarget.Edge.Right,
CropDragTarget.Edge.Bottom,
CropDragTarget.Edge.Left,
CropDragTarget.Corner.TopLeft,
CropDragTarget.Corner.TopRight,
CropDragTarget.Corner.BottomRight,
CropDragTarget.Corner.BottomLeft,
CropDragTarget.Move,
).forEach { target ->
val color = when (target) {
is CropDragTarget.Move -> Color.Red
is CropDragTarget.Corner -> Color.Blue
is CropDragTarget.Edge -> Color.Green
}.copy(alpha = if (dragTarget == target) 9f else 0.5f)
drawCircle(
color = color,
radius = touchRadiusPx,
center = computeOffset(target, cropRect, Size(size.width, size.height)),
)
}
}
}
}
private fun fitSize(
containerWidth: Float,
containerHeight: Float,
aspectRatio: Float,
): Size {
val widthBasedHeight = containerWidth / aspectRatio
return if (widthBasedHeight <= containerHeight) {
Size(width = containerWidth, height = widthBasedHeight)
} else {
Size(width = containerHeight * aspectRatio, height = containerHeight)
}
}
private fun detectDragTarget(
touchPoint: Offset,
imageOffset: Offset,
cropRect: NormalizedCropRect,
canvasSize: Size,
handleTouchRadius: Float,
): CropDragTarget? {
// Give priority on Move (extra detection of the center of crop area)
// to ensure that user can move a small crop, then to corners and at last to edges.
val handlesArea = mapOf(
CropDragTarget.Move to computeOffset(CropDragTarget.Move, cropRect, canvasSize),
CropDragTarget.Corner.TopLeft to computeOffset(CropDragTarget.Corner.TopLeft, cropRect, canvasSize),
CropDragTarget.Corner.TopRight to computeOffset(CropDragTarget.Corner.TopRight, cropRect, canvasSize),
CropDragTarget.Corner.BottomRight to computeOffset(CropDragTarget.Corner.BottomRight, cropRect, canvasSize),
CropDragTarget.Corner.BottomLeft to computeOffset(CropDragTarget.Corner.BottomLeft, cropRect, canvasSize),
CropDragTarget.Edge.Top to computeOffset(CropDragTarget.Edge.Top, cropRect, canvasSize),
CropDragTarget.Edge.Right to computeOffset(CropDragTarget.Edge.Right, cropRect, canvasSize),
CropDragTarget.Edge.Bottom to computeOffset(CropDragTarget.Edge.Bottom, cropRect, canvasSize),
CropDragTarget.Edge.Left to computeOffset(CropDragTarget.Edge.Left, cropRect, canvasSize),
)
handlesArea.forEach { (target, corner) ->
if ((corner - touchPoint + imageOffset).getDistance() <= handleTouchRadius) {
return target
}
}
val cropLeft = imageOffset.x + cropRect.left * canvasSize.width
val cropTop = imageOffset.y + cropRect.top * canvasSize.height
val cropRight = imageOffset.x + cropRect.right * canvasSize.width
val cropBottom = imageOffset.y + cropRect.bottom * canvasSize.height
return if (touchPoint.x in cropLeft..cropRight && touchPoint.y in cropTop..cropBottom) {
CropDragTarget.Move
} else {
null
}
}
private fun computeOffset(
target: CropDragTarget,
cropRect: NormalizedCropRect,
canvasSize: Size,
) = when (target) {
CropDragTarget.Move -> Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f)
CropDragTarget.Corner.TopLeft -> Offset(cropRect.left * canvasSize.width, cropRect.top * canvasSize.height)
CropDragTarget.Edge.Top -> Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, cropRect.top * canvasSize.height)
CropDragTarget.Corner.TopRight -> Offset(cropRect.right * canvasSize.width, cropRect.top * canvasSize.height)
CropDragTarget.Edge.Right -> Offset(cropRect.right * canvasSize.width, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f)
CropDragTarget.Corner.BottomRight -> Offset(cropRect.right * canvasSize.width, cropRect.bottom * canvasSize.height)
CropDragTarget.Edge.Bottom -> Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, cropRect.bottom * canvasSize.height)
CropDragTarget.Corner.BottomLeft -> Offset(cropRect.left * canvasSize.width, cropRect.bottom * canvasSize.height)
CropDragTarget.Edge.Left -> Offset(cropRect.left * canvasSize.width, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f)
}
// x and y are the coordinates of the corner
private fun DrawScope.drawCornerHandle(
x: Float,
y: Float,
handleLength: Float,
color: Color,
position: CropDragTarget.Corner,
) {
val strokeWidth = 4.dp.toPx()
val correction = strokeWidth / 2
val horizontalCorrection = if (position.isLeft()) -correction else correction
val horizontalEndX = if (position.isLeft()) x + handleLength else x - handleLength
val verticalEndY = if (position.isTop()) y + handleLength else y - handleLength
val verticalCorrection = if (position.isTop()) -correction else correction
// Horizontal line
drawLine(
color = color,
start = Offset(x + horizontalCorrection, y),
end = Offset(horizontalEndX + horizontalCorrection, y),
strokeWidth = strokeWidth,
)
// Vertical line
drawLine(
color = color,
start = Offset(x, y + verticalCorrection),
end = Offset(x, verticalEndY + verticalCorrection),
strokeWidth = strokeWidth,
)
}
private fun CropDragTarget.Corner.isLeft() = this == CropDragTarget.Corner.TopLeft || this == CropDragTarget.Corner.BottomLeft
private fun CropDragTarget.Corner.isTop() = this == CropDragTarget.Corner.TopLeft || this == CropDragTarget.Corner.TopRight
private fun DrawScope.drawEdgeHandle(
center: Offset,
horizontal: Boolean,
handleLength: Float,
color: Color,
) {
val start = if (horizontal) {
Offset(center.x - handleLength / 2f, center.y)
} else {
Offset(center.x, center.y - handleLength / 2f)
}
val end = if (horizontal) {
Offset(center.x + handleLength / 2f, center.y)
} else {
Offset(center.x, center.y + handleLength / 2f)
}
drawLine(
color = color,
start = start,
end = end,
strokeWidth = 4.dp.toPx(),
)
}
// Only preview in dark, dark theme is forced on the Node.
@Preview
@Composable
internal fun AttachmentImageEditorViewPreview(
@PreviewParameter(AttachmentImageEditorStateProvider::class) state: AttachmentImageEditorState,
) = ElementPreviewDark {
AttachmentImageEditorView(
state = state,
onCropRectChange = {},
onRotateClick = {},
onFlipHorizontallyClick = {},
onFlipVerticallyClick = {},
onResetClick = {},
onCancelClick = {},
onDoneClick = {},
)
}
@@ -36,17 +36,22 @@ import kotlin.math.roundToLong
@AssistedInject
class DefaultMediaOptimizationSelectorPresenter(
@Assisted private val index: Int,
@Assisted private val localMedia: LocalMedia,
@Assisted private val sendAsFile: Boolean,
private val maxUploadSizeProvider: MaxUploadSizeProvider,
private val featureFlagService: FeatureFlagService,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
private val videoCompressionPresetSelector: VideoCompressionPresetSelector,
mediaExtractorFactory: VideoMetadataExtractor.Factory,
) : MediaOptimizationSelectorPresenter {
@ContributesBinding(SessionScope::class)
@AssistedFactory
interface Factory : MediaOptimizationSelectorPresenter.Factory {
override fun create(
index: Int,
localMedia: LocalMedia,
sendAsFile: Boolean,
): DefaultMediaOptimizationSelectorPresenter
}
@@ -55,7 +60,9 @@ class DefaultMediaOptimizationSelectorPresenter(
@Composable
override fun present(): MediaOptimizationSelectorState {
val displayMediaSelectorViews by produceState<Boolean?>(null) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.SelectableMediaQuality)
// When sending as a raw file, never show the optimization selector: images skip
// recompression, while videos use the highest available best-fit preset.
value = !sendAsFile && featureFlagService.isFeatureEnabled(FeatureFlags.SelectableMediaQuality)
}
var displayVideoPresetSelectorDialog by remember { mutableStateOf(false) }
@@ -123,12 +130,23 @@ class DefaultMediaOptimizationSelectorPresenter(
var selectedVideoOptimizationPreset by remember { mutableStateOf<AsyncData<VideoCompressionPreset>>(AsyncData.Loading()) }
LaunchedEffect(videoSizeEstimations.dataOrNull()) {
if (sendAsFile) {
// Send-as-file path: pin to no image compression, and pick the highest-quality
// video preset that still fits the upload limit (we have no true "do not re-encode
// video" path in the pre-processor right now).
selectedImageOptimization = AsyncData.Success(false)
selectedVideoOptimizationPreset = videoCompressionPresetSelector.selectBestVideoPreset(
expectedVideoPreset = VideoCompressionPreset.HIGH,
videoSizeEstimations = videoSizeEstimations,
)
return@LaunchedEffect
}
val mediaOptimizationConfig = mediaOptimizationConfigProvider.get()
selectedImageOptimization = AsyncData.Success(mediaOptimizationConfig.compressImages)
// Find the best video preset based on the default preset and the video size estimations
// Since the estimation for the current preset may be way too large to upload, we check the ones that provide lower file sizes
selectedVideoOptimizationPreset = findBestVideoPreset(
defaultVideoPreset = mediaOptimizationConfig.videoCompressionPreset,
selectedVideoOptimizationPreset = videoCompressionPresetSelector.selectBestVideoPreset(
expectedVideoPreset = mediaOptimizationConfig.videoCompressionPreset,
videoSizeEstimations = videoSizeEstimations,
)
}
@@ -167,6 +185,7 @@ class DefaultMediaOptimizationSelectorPresenter(
}
return MediaOptimizationSelectorState(
index = index,
maxUploadSize = maxUploadSize,
videoSizeEstimations = videoSizeEstimations,
isImageOptimizationEnabled = selectedImageOptimization.dataOrNull(),
@@ -176,20 +195,4 @@ class DefaultMediaOptimizationSelectorPresenter(
eventSink = ::handleEvent,
)
}
private fun findBestVideoPreset(
defaultVideoPreset: VideoCompressionPreset,
videoSizeEstimations: AsyncData<ImmutableList<VideoUploadEstimation>>,
): AsyncData<VideoCompressionPreset> {
val estimations = videoSizeEstimations.dataOrNull() ?: return AsyncData.Loading()
// This will find the best video preset that can be used to produce a video that can be uploaded
val bestEstimation = estimations.find { it.preset.ordinal >= defaultVideoPreset.ordinal && it.canUpload }?.preset
return if (bestEstimation != null) {
AsyncData.Success(bestEstimation)
} else {
AsyncData.Failure(
IllegalStateException("No suitable video preset found for default preset: $defaultVideoPreset")
)
}
}
}
@@ -14,7 +14,9 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMedia
fun interface MediaOptimizationSelectorPresenter : Presenter<MediaOptimizationSelectorState> {
interface Factory {
fun create(
index: Int,
localMedia: LocalMedia,
sendAsFile: Boolean,
): MediaOptimizationSelectorPresenter
}
}
@@ -13,6 +13,8 @@ import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import kotlinx.collections.immutable.ImmutableList
data class MediaOptimizationSelectorState(
// Adding an index helps to fix a Compose issue where the state of the wrong item is updated
val index: Int,
val maxUploadSize: AsyncData<Long>,
val videoSizeEstimations: AsyncData<ImmutableList<VideoUploadEstimation>>,
val isImageOptimizationEnabled: Boolean?,
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.attachments.video
import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import kotlinx.collections.immutable.ImmutableList
@Inject
class VideoCompressionPresetSelector {
fun selectBestVideoPreset(
expectedVideoPreset: VideoCompressionPreset,
videoSizeEstimations: AsyncData<ImmutableList<VideoUploadEstimation>>,
): AsyncData<VideoCompressionPreset> {
val estimations = videoSizeEstimations.dataOrNull() ?: return AsyncData.Loading()
val bestEstimation = estimations.find { it.preset.ordinal >= expectedVideoPreset.ordinal && it.canUpload }?.preset
return if (bestEstimation != null) {
AsyncData.Success(bestEstimation)
} else {
AsyncData.Failure(
IllegalStateException("No suitable video preset found for expected preset: $expectedVideoPreset")
)
}
}
}
@@ -30,7 +30,6 @@ interface VideoMetadataExtractor : AutoCloseable {
}
}
@ContributesBinding(AppScope::class)
@AssistedInject
class DefaultVideoMetadataExtractor(
@ApplicationContext private val context: Context,
@@ -11,6 +11,7 @@ package io.element.android.features.messages.impl.crypto.identity
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.ui.room.IdentityRoomMember
@@ -32,7 +33,7 @@ class IdentityChangeStateProvider : PreviewParameterProvider<IdentityChangeState
anIdentityChangeState(
roomMemberIdentityStateChanges = listOf(
aRoomMemberIdentityStateChange(
identityRoomMember = anIdentityRoomMember(displayNameOrDefault = "Alice"),
identityRoomMember = anIdentityRoomMember(displayNameOrDefault = USER_NAME_ALICE),
identityState = IdentityState.VerificationViolation,
),
),
@@ -35,12 +35,15 @@ internal fun MessagesViewWithIdentityChangePreview(
onBackClick = {},
onRoomDetailsClick = {},
onEventContentClick = { _, _ -> false },
onGalleryEventItemClick = { _, _, _ -> false },
onUserDataClick = {},
onLinkClick = { _, _ -> },
onSendLocationClick = {},
onCreatePollClick = {},
onJoinCallClick = {},
onViewAllPinnedMessagesClick = {},
knockRequestsBannerView = {}
knockRequestsBannerView = {},
customReactionBottomSheet = {},
onThreadsListClick = {},
)
}
@@ -11,6 +11,7 @@ package io.element.android.features.messages.impl.crypto.sendfailure.resolve
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
open class ResolveVerifiedUserSendFailureStateProvider : PreviewParameterProvider<ResolveVerifiedUserSendFailureState> {
override val values: Sequence<ResolveVerifiedUserSendFailureState>
@@ -37,10 +38,10 @@ fun aResolveVerifiedUserSendFailureState(
eventSink = eventSink
)
fun anUnsignedDeviceSendFailure(userDisplayName: String = "Alice") = VerifiedUserSendFailure.UnsignedDevice.FromOther(
fun anUnsignedDeviceSendFailure(userDisplayName: String = USER_NAME_ALICE) = VerifiedUserSendFailure.UnsignedDevice.FromOther(
userDisplayName = userDisplayName,
)
fun aChangedIdentitySendFailure(userDisplayName: String = "Alice") = VerifiedUserSendFailure.ChangedIdentity(
fun aChangedIdentitySendFailure(userDisplayName: String = USER_NAME_ALICE) = VerifiedUserSendFailure.ChangedIdentity(
userDisplayName = userDisplayName,
)
@@ -14,7 +14,8 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -43,7 +44,10 @@ fun ResolveVerifiedUserSendFailureView(
state: ResolveVerifiedUserSendFailureState,
modifier: Modifier = Modifier,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
)
var showSheet by remember { mutableStateOf(false) }
fun dismiss() {
@@ -75,6 +79,7 @@ fun ResolveVerifiedUserSendFailureView(
.navigationBarsPadding(),
sheetState = sheetState,
onDismissRequest = ::dismiss,
scrollable = true,
) {
IconTitleSubtitleMolecule(
modifier = Modifier.padding(24.dp),
@@ -13,8 +13,11 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -32,7 +35,6 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text
@@ -71,10 +73,12 @@ internal fun AttachmentsBottomSheet(
if (isVisible) {
ModalBottomSheet(
modifier = modifier,
sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
),
onDismissRequest = { isVisible = false }
onDismissRequest = { isVisible = false },
scrollable = false,
) {
AttachmentSourcePickerMenu(
state = state,
@@ -97,30 +101,27 @@ private fun AttachmentSourcePickerMenu(
modifier = Modifier
.navigationBarsPadding()
.imePadding()
.verticalScroll(rememberScrollState())
) {
ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.PickAttachmentSource.PhotoFromCamera) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.TakePhoto())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) },
style = ListItemStyle.Primary,
content = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) },
)
ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.PickAttachmentSource.VideoFromCamera) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.VideoCall())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
style = ListItemStyle.Primary,
content = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
)
ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.PickAttachmentSource.FromGallery) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Image())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) },
style = ListItemStyle.Primary,
content = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) },
)
ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.PickAttachmentSource.FromFiles) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Attachment())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_files)) },
style = ListItemStyle.Primary,
content = { Text(stringResource(R.string.screen_room_attachment_source_files)) },
)
if (state.canShareLocation) {
ListItem(
@@ -129,8 +130,7 @@ private fun AttachmentSourcePickerMenu(
onSendLocationClick()
},
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.LocationPin())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_location)) },
style = ListItemStyle.Primary,
content = { Text(stringResource(R.string.screen_room_attachment_source_location)) },
)
}
ListItem(
@@ -139,15 +139,13 @@ private fun AttachmentSourcePickerMenu(
onCreatePollClick()
},
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Polls())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_poll)) },
style = ListItemStyle.Primary,
content = { Text(stringResource(R.string.screen_room_attachment_source_poll)) },
)
if (enableTextFormatting) {
ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.ToggleTextFormatting(enabled = true)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.TextFormatting())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_text_formatting)) },
style = ListItemStyle.Primary,
content = { Text(stringResource(R.string.screen_room_attachment_text_formatting)) },
)
}
}
@@ -155,10 +153,11 @@ private fun AttachmentSourcePickerMenu(
@PreviewsDayNight
@Composable
internal fun AttachmentSourcePickerMenuPreview() = ElementPreview {
AttachmentSourcePickerMenu(
internal fun AttachmentsBottomSheetPreview() = ElementPreview(fillMaxSize = true) {
AttachmentsBottomSheet(
state = aMessageComposerState(
canShareLocation = true,
showAttachmentSourcePicker = true,
),
onSendLocationClick = {},
onCreatePollClick = {},
@@ -36,4 +36,5 @@ sealed interface MessageComposerEvent {
data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvent
data class InsertSuggestion(val resolvedSuggestion: ResolvedSuggestion) : MessageComposerEvent
data object SaveDraft : MessageComposerEvent
data object ClearSlashError : MessageComposerEvent
}
@@ -15,6 +15,7 @@ import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
@@ -30,6 +31,7 @@ import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.Composer
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.contentscanner.api.ContentScannerService
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
@@ -39,13 +41,17 @@ import io.element.android.features.messages.impl.messagecomposer.suggestions.Roo
import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsProcessor
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.utils.TextPillificationHelper
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.extensions.runCatchingExceptions
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
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
@@ -54,11 +60,14 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
import io.element.android.libraries.matrix.api.room.getDirectRoomMember
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.use
import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.mediaSources
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.content
import io.element.android.libraries.matrix.ui.messages.reply.eventId
import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
@@ -68,6 +77,9 @@ import io.element.android.libraries.permissions.api.PermissionsEvent
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
import io.element.android.libraries.slashcommands.api.SlashCommand
import io.element.android.libraries.slashcommands.api.SlashCommandService
import io.element.android.libraries.slashcommands.api.message
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
@@ -104,6 +116,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
class MessageComposerPresenter(
@Assisted private val navigator: MessagesNavigator,
@Assisted private val timelineController: TimelineController,
@Assisted private val threadRoot: ThreadId?,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val room: JoinedRoom,
private val mediaPickerProvider: PickerProvider,
@@ -125,12 +138,21 @@ class MessageComposerPresenter(
private val suggestionsProcessor: SuggestionsProcessor,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
private val notificationConversationService: NotificationConversationService,
private val slashCommandService: SlashCommandService,
private val featureFlagService: FeatureFlagService,
private val contentScannerService: ContentScannerService,
private val contentValidationCache: EventContentValidationCache,
) : Presenter<MessageComposerState> {
@AssistedFactory
interface Factory {
fun create(timelineController: TimelineController, navigator: MessagesNavigator): MessageComposerPresenter
fun create(
timelineController: TimelineController,
navigator: MessagesNavigator,
threadRoot: ThreadId?,
): MessageComposerPresenter
}
private val isInThread = threadRoot != null
private val mediaSender = mediaSenderFactory.create(timelineMode = timelineController.mainTimelineMode())
private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
@@ -164,11 +186,20 @@ 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 ->
handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream)
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 ->
handlePickedMedia(uri, MimeTypes.Jpeg)
@@ -218,11 +249,12 @@ class MessageComposerPresenter(
}
)
val slashCommandAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
LaunchedEffect(Unit) {
val draft = draftService.loadDraft(
roomId = room.roomId,
// TODO support threads in composer
threadRoot = null,
threadRoot = threadRoot,
isVolatile = false
)
if (draft != null) {
@@ -246,6 +278,7 @@ class MessageComposerPresenter(
sessionCoroutineScope.sendMessage(
markdownTextEditorState = markdownTextEditorState,
richTextEditorState = richTextEditorState,
slashCommandAction = slashCommandAction,
)
}
is MessageComposerEvent.SendUri -> {
@@ -262,8 +295,7 @@ class MessageComposerPresenter(
inReplyToEventId = inReplyToEventId,
)
// Reset composer since the attachment has been sent
messageComposerContext.composerMode = MessageComposerMode.Normal
resetComposerModeAfterAttaching()
}
is MessageComposerEvent.SetMode -> {
localCoroutineScope.setMode(event.composerMode, markdownTextEditorState, richTextEditorState)
@@ -274,11 +306,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
@@ -340,6 +380,9 @@ class MessageComposerPresenter(
val link = permalinkBuilder.permalinkForRoomAlias(suggestion.roomAlias).getOrNull() ?: return@launch
richTextEditorState.insertMentionAtSuggestion(text = text, link = link)
}
is ResolvedSuggestion.Command -> {
richTextEditorState.replaceSuggestion(suggestion.command.command)
}
}
} else if (markdownTextEditorState.currentSuggestion != null) {
markdownTextEditorState.insertSuggestion(
@@ -354,6 +397,9 @@ class MessageComposerPresenter(
val draft = createDraftFromState(markdownTextEditorState, richTextEditorState)
sessionCoroutineScope.updateDraft(draft, isVolatile = false)
}
MessageComposerEvent.ClearSlashError -> {
slashCommandAction.value = AsyncAction.Uninitialized
}
}
}
@@ -385,6 +431,7 @@ class MessageComposerPresenter(
suggestions = suggestions.toImmutableList(),
resolveMentionDisplay = resolveMentionDisplay,
resolveAtRoomMentionDisplay = resolveAtRoomMentionDisplay,
slashCommandAction = slashCommandAction.value,
eventSink = ::handleEvent,
)
}
@@ -422,6 +469,7 @@ class MessageComposerPresenter(
roomAliasSuggestions = roomAliasSuggestions,
currentUserId = currentUserId,
canSendRoomMention = ::canSendRoomMention,
isInThread = isInThread,
)
suggestions.clear()
suggestions.addAll(result)
@@ -433,9 +481,69 @@ class MessageComposerPresenter(
private fun CoroutineScope.sendMessage(
markdownTextEditorState: MarkdownTextEditorState,
richTextEditorState: RichTextEditorState,
slashCommandAction: MutableState<AsyncAction<Unit>>,
) = launch {
val message = currentComposerMessage(markdownTextEditorState, richTextEditorState, withMentions = true)
val capturedMode = messageComposerContext.composerMode
val slashCommand = if (capturedMode is MessageComposerMode.Normal) {
slashCommandService.parse(
textMessage = message.markdown,
formattedMessage = message.html,
isInThreadTimeline = isInThread,
)
} else {
SlashCommand.NotACommand
}
when (slashCommand) {
is SlashCommand.NotACommand -> Unit
is SlashCommand.Error -> {
slashCommandAction.value = AsyncAction.Failure(Exception(slashCommand.message()))
return@launch
}
is SlashCommand.SlashCommandNavigation -> {
when (slashCommand) {
is SlashCommand.ShowUser -> {
navigator.navigateToMember(slashCommand.userId)
}
SlashCommand.DevTools -> {
navigator.navigateToDeveloperSettings()
}
}
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit)
return@launch
}
is SlashCommand.SlashCommandSendMessage -> {
timelineController.invokeOnCurrentTimeline {
slashCommandService.proceedSendMessage(slashCommand, this)
.onFailure { cause ->
Timber.e(cause, "Failed to proceed with admin slash command")
slashCommandAction.value = AsyncAction.Failure(cause)
}
.onSuccess {
// Reset composer
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit)
}
}
return@launch
}
is SlashCommand.SlashCommandAdmin -> {
slashCommandAction.value = AsyncAction.Loading
slashCommandService.proceedAdmin(slashCommand)
.onFailure { cause ->
Timber.e(cause, "Failed to proceed with admin slash command")
slashCommandAction.value = AsyncAction.Failure(cause)
}
.onSuccess {
// Reset composer
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit)
slashCommandAction.value = AsyncAction.Uninitialized
}
return@launch
}
}
// Reset composer right away
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit)
when (capturedMode) {
@@ -489,7 +597,7 @@ class MessageComposerPresenter(
notificationConversationService.onSendMessage(
sessionId = room.sessionId,
roomId = roomInfo.id,
roomName = roomInfo.name ?: roomInfo.id.value,
roomName = roomInfo.name,
roomIsDirect = roomInfo.isDm,
roomAvatarUrl = roomInfo.avatarUrl ?: roomMembers.getDirectRoomMember(roomInfo = roomInfo, sessionId = room.sessionId)?.avatarUrl,
)
@@ -523,6 +631,7 @@ class MessageComposerPresenter(
private fun handlePickedMedia(
uri: Uri?,
mimeType: String? = null,
sendAsFile: Boolean = false,
) {
uri ?: return
val localMedia = localMediaFactory.createFromUri(
@@ -531,12 +640,43 @@ class MessageComposerPresenter(
name = null,
formattedFileSize = null
)
val mediaAttachment = Attachment.Media(localMedia)
val mediaAttachment = Attachment.Media(localMedia, sendAsFile = sendAsFile)
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
navigator.navigateToPreviewAttachments(persistentListOf(mediaAttachment), inReplyToEventId)
// Reset composer since the attachment will be sent in a separate flow
messageComposerContext.composerMode = MessageComposerMode.Normal
resetComposerModeAfterAttaching()
}
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)
resetComposerModeAfterAttaching()
}
private fun resetComposerModeAfterAttaching() {
// An attachment is sent as its own message, so a Reply/Normal mode is consumed and reset.
// An in-progress edit must survive: the typed text still edits the original on the next send.
if (!messageComposerContext.composerMode.isEditing) {
messageComposerContext.composerMode = MessageComposerMode.Normal
}
}
private suspend fun sendMedia(
@@ -569,8 +709,7 @@ class MessageComposerPresenter(
roomId = room.roomId,
draft = draft,
isVolatile = isVolatile,
// TODO support threads in composer
threadRoot = null,
threadRoot = threadRoot,
)
}
@@ -716,6 +855,11 @@ class MessageComposerPresenter(
setText(newComposerMode.content, markdownTextEditorState, richTextEditorState, requestFocus = true)
}
else -> {
if (currentComposerMode is MessageComposerMode.Reply) {
val mediaSources = currentComposerMode.replyToDetails.content()?.mediaSources() ?: return@launch
val contentValidationState = contentValidationCache[currentComposerMode.replyToDetails.eventId()]
contentScannerService.scan(mediaSources, contentValidationState)
}
// When coming from edit, just clear the composer as it'd be weird to reset a volatile draft in this scenario.
if (currentComposerMode.isEditing) {
setText("", markdownTextEditorState, richTextEditorState)
@@ -733,8 +877,7 @@ class MessageComposerPresenter(
// Use the volatile draft only when coming from edit mode otherwise.
val draft = draftService.loadDraft(
roomId = room.roomId,
// TODO support threads in composer
threadRoot = null,
threadRoot = threadRoot,
isVolatile = true
).takeIf { fromEdit }
if (draft != null) {
@@ -9,6 +9,7 @@
package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.runtime.Stable
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.TextEditorState
@@ -26,5 +27,6 @@ data class MessageComposerState(
val suggestions: ImmutableList<ResolvedSuggestion>,
val resolveMentionDisplay: (String, String) -> TextDisplay,
val resolveAtRoomMentionDisplay: () -> TextDisplay,
val slashCommandAction: AsyncAction<Unit>,
val eventSink: (MessageComposerEvent) -> Unit,
)
@@ -9,6 +9,7 @@
package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.TextEditorState
@@ -32,6 +33,7 @@ fun aMessageComposerState(
showAttachmentSourcePicker: Boolean = false,
canShareLocation: Boolean = true,
suggestions: ImmutableList<ResolvedSuggestion> = persistentListOf(),
slashCommandAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (MessageComposerEvent) -> Unit = {},
) = MessageComposerState(
textEditorState = textEditorState,
@@ -43,5 +45,6 @@ fun aMessageComposerState(
suggestions = suggestions,
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
resolveAtRoomMentionDisplay = { TextDisplay.Plain },
slashCommandAction = slashCommandAction,
eventSink = eventSink,
)
@@ -22,6 +22,7 @@ import io.element.android.features.messages.api.timeline.voicemessages.composer.
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerStateProvider
import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessageComposerState
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.textcomposer.TextComposer
@@ -83,6 +84,7 @@ internal fun MessageComposerView(
val onSendVoiceMessage = {
voiceMessageState.eventSink(VoiceMessageComposerEvent.SendVoiceMessage)
state.eventSink(MessageComposerEvent.CloseSpecialMode)
}
val onDeleteVoiceMessage = {
@@ -115,6 +117,12 @@ internal fun MessageComposerView(
onTyping = ::onTyping,
onSelectRichContent = ::sendUri,
)
AsyncActionView(
async = state.slashCommandAction,
onSuccess = {},
onErrorDismiss = { state.eventSink(MessageComposerEvent.ClearSlashError) },
)
}
@PreviewsDayNight
@@ -29,9 +29,11 @@ import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.avatar.AvatarType.Room
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.USER_NAME_BOB
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.RoomAlias
@@ -39,7 +41,10 @@ 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.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.getBestName
import io.element.android.libraries.matrix.ui.components.DisplayNameWithStatus
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -63,6 +68,7 @@ fun SuggestionsPickerView(
is ResolvedSuggestion.AtRoom -> "@room"
is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value
is ResolvedSuggestion.Alias -> suggestion.roomId.value
is ResolvedSuggestion.Command -> suggestion.command.command
}
}
) {
@@ -91,54 +97,85 @@ private fun SuggestionItemView(
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.clickable { onSelectSuggestion(suggestion) },
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier
.clickable { onSelectSuggestion(suggestion) }
.padding(horizontal = 16.dp),
) {
val avatarSize = AvatarSize.Suggestion
val avatarData = when (suggestion) {
is ResolvedSuggestion.AtRoom -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize)
is ResolvedSuggestion.Member -> suggestion.roomMember.getAvatarData(avatarSize)
is ResolvedSuggestion.Alias -> suggestion.getAvatarData(avatarSize)
is ResolvedSuggestion.Command -> null
}
val avatarType = when (suggestion) {
is ResolvedSuggestion.Alias -> AvatarType.Room()
is ResolvedSuggestion.Alias -> Room()
ResolvedSuggestion.AtRoom,
is ResolvedSuggestion.Member -> AvatarType.User
is ResolvedSuggestion.Command -> null
}
val title = when (suggestion) {
val title: String? = when (suggestion) {
is ResolvedSuggestion.AtRoom -> stringResource(R.string.screen_room_mentions_at_room_title)
is ResolvedSuggestion.Member -> suggestion.roomMember.displayName
is ResolvedSuggestion.Member -> suggestion.roomMember.getBestName()
is ResolvedSuggestion.Alias -> suggestion.roomName
is ResolvedSuggestion.Command -> suggestion.command.command
}
val status = when (suggestion) {
is ResolvedSuggestion.Member -> suggestion.roomMember.displayedStatus
else -> null
}
val details = when (suggestion) {
is ResolvedSuggestion.AtRoom,
is ResolvedSuggestion.Member,
is ResolvedSuggestion.Alias -> null
is ResolvedSuggestion.Command -> suggestion.command.parameters
}
val subtitle = when (suggestion) {
is ResolvedSuggestion.AtRoom -> "@room"
is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value
is ResolvedSuggestion.Alias -> suggestion.roomAlias.value
is ResolvedSuggestion.Command -> suggestion.command.description
}
if (avatarData != null && avatarType != null) {
Avatar(
avatarData = avatarData,
avatarType = avatarType,
modifier = Modifier.padding(top = 12.dp, bottom = 12.dp, end = 16.dp),
)
}
Avatar(
avatarData = avatarData,
avatarType = avatarType,
modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp),
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(end = 16.dp, top = 8.dp, bottom = 8.dp)
.padding(top = 8.dp, bottom = 8.dp)
.align(Alignment.CenterVertically),
) {
title?.let {
Text(
text = it,
style = ElementTheme.typography.fontBodyLgRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
title?.let {
DisplayNameWithStatus(
name = title,
status = status,
style = ElementTheme.typography.fontBodyLgRegular,
nameColor = ElementTheme.colors.textPrimary,
)
}
details?.let {
Text(
text = it,
style = ElementTheme.typography.fontBodyMdRegular,
maxLines = 1,
color = ElementTheme.colors.textSecondary,
overflow = TextOverflow.Ellipsis,
)
}
}
Text(
text = subtitle,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
@@ -159,6 +196,8 @@ internal fun SuggestionsPickerViewPreview() {
isIgnored = false,
role = RoomMember.Role.User,
membershipChangeReason = null,
isServiceMember = false,
displayedStatus = null,
)
val anAlias = remember { RoomAlias("#room:domain.org") }
SuggestionsPickerView(
@@ -168,13 +207,27 @@ internal fun SuggestionsPickerViewPreview() {
suggestions = persistentListOf(
ResolvedSuggestion.AtRoom,
ResolvedSuggestion.Member(roomMember),
ResolvedSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")),
ResolvedSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = USER_NAME_BOB)),
ResolvedSuggestion.Alias(
roomAlias = anAlias,
roomId = RoomId("!room:matrix.org"),
roomName = "My room",
roomAvatarUrl = null,
)
),
ResolvedSuggestion.Command(
command = SlashCommandSuggestion(
command = "/noparam",
parameters = null,
description = "A slash command without parameters",
)
),
ResolvedSuggestion.Command(
command = SlashCommandSuggestion(
command = "/withparam",
parameters = "<user-id> [reason]",
description = "A slash command with parameters",
)
),
),
onSelectSuggestion = {}
)
@@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.slashcommands.api.SlashCommandService
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.SuggestionType
@@ -23,7 +24,9 @@ import io.element.android.libraries.textcomposer.model.SuggestionType
* This class is responsible for processing suggestions when `@`, `/` or `#` are type in the composer.
*/
@Inject
class SuggestionsProcessor {
class SuggestionsProcessor(
private val slashCommandService: SlashCommandService,
) {
/**
* Process the suggestion.
* @param suggestion The current suggestion input
@@ -31,6 +34,7 @@ class SuggestionsProcessor {
* @param roomAliasSuggestions The available room alias suggestions
* @param currentUserId The current user id
* @param canSendRoomMention Should return true if the current user can send room mentions
* @param isInThread Whether the composer is in a thread or not, used to filter slash commands suggestions
* @return The list of suggestions to display
*/
suspend fun process(
@@ -39,6 +43,7 @@ class SuggestionsProcessor {
roomAliasSuggestions: List<RoomAliasSuggestion>,
currentUserId: UserId,
canSendRoomMention: suspend () -> Boolean,
isInThread: Boolean,
): List<ResolvedSuggestion> {
suggestion ?: return emptyList()
return when (suggestion.type) {
@@ -69,7 +74,16 @@ class SuggestionsProcessor {
)
}
}
SuggestionType.Command,
SuggestionType.Command -> {
// Command suggestions are valid only if this is the beginning of the message
if (suggestion.start == 0) {
slashCommandService.getSuggestions(suggestion.text, isInThread).map {
ResolvedSuggestion.Command(it)
}
} else {
emptyList()
}
}
SuggestionType.Emoji,
is SuggestionType.Custom -> {
// Clear suggestions
@@ -16,10 +16,15 @@ 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.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
@@ -98,7 +103,8 @@ private fun PinnedMessagesBannerRow(
onClick(state.currentPinnedMessage.eventId)
state.eventSink(PinnedMessagesBannerEvent.MoveToNextPinned)
}
},
}
.padding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal).asPaddingValues()),
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(modifier = Modifier.width(26.dp))
@@ -10,7 +10,9 @@ package io.element.android.features.messages.impl.pinned.list
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.matrix.api.core.ThreadId
sealed interface PinnedMessagesListEvent {
data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : PinnedMessagesListEvent
data class OpenThread(val threadRootId: ThreadId) : PinnedMessagesListEvent
}
@@ -9,10 +9,12 @@
package io.element.android.features.messages.impl.pinned.list
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.timeline.item.TimelineItemDebugInfo
interface PinnedMessagesListNavigator {
fun viewInTimeline(eventId: EventId)
fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
fun forwardEvent(eventId: EventId)
fun navigateToThread(threadRootId: ThreadId)
}
@@ -31,12 +31,15 @@ import io.element.android.libraries.androidutils.system.openUrlInExternalApp
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.RoomScope
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
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.a11y.hasExternalKeyboard
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
@ContributesNode(RoomScope::class)
@AssistedInject
@@ -49,12 +52,14 @@ class PinnedMessagesListNode(
private val permalinkParser: PermalinkParser,
) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator {
interface Callback : Plugin {
fun handleEventClick(event: TimelineItem.Event)
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)
fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
fun handleForwardEventClick(eventId: EventId)
fun navigateToThread(threadRootId: ThreadId)
}
private val callback: Callback = callback()
@@ -95,8 +100,13 @@ class PinnedMessagesListNode(
callback.handleForwardEventClick(eventId)
}
override fun navigateToThread(threadRootId: ThreadId) {
callback.navigateToThread(threadRootId)
}
@Composable
override fun View(modifier: Modifier) {
val canUseOverlay = !isTalkbackActive() && !hasExternalKeyboard()
CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
) {
@@ -107,7 +117,12 @@ class PinnedMessagesListNode(
PinnedMessagesListView(
state = state,
onBackClick = ::navigateUp,
onEventClick = callback::handleEventClick,
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 = {
@@ -44,7 +44,6 @@ 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.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.ui.strings.CommonStrings
@@ -137,6 +136,7 @@ class PinnedMessagesListPresenter(
fun handleEvent(event: PinnedMessagesListEvent) {
when (event) {
is PinnedMessagesListEvent.HandleAction -> sessionCoroutineScope.handleTimelineAction(event.action, event.event)
is PinnedMessagesListEvent.OpenThread -> navigator.navigateToThread(event.threadRootId)
}
}
@@ -208,7 +208,8 @@ class PinnedMessagesListPresenter(
combine(timelineItemsFlow, room.membersStateFlow) { items, membersState ->
timelineItemsFactory.replaceWith(
timelineItems = items,
roomMembers = membersState.roomMembers().orEmpty()
roomMembers = membersState.roomMembers().orEmpty(),
renderReadReceipts = false,
)
}.launchIn(this)
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.plus
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -30,12 +31,12 @@ import io.element.android.features.messages.impl.actionlist.ActionListView
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.link.LinkEvent
import io.element.android.features.messages.impl.link.LinkView
import io.element.android.features.messages.impl.timeline.TimelineEvent
import io.element.android.features.messages.impl.timeline.components.TimelineItemRow
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.poll.api.pollcontent.PollTitleView
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
@@ -47,6 +48,8 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
@@ -59,6 +62,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,
@@ -80,6 +84,7 @@ fun PinnedMessagesListView(
PinnedMessagesListContent(
state = state,
onEventClick = onEventClick,
onGalleryItemClick = onGalleryItemClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
@@ -88,7 +93,8 @@ fun PinnedMessagesListView(
.padding(padding)
.consumeWindowInsets(padding),
)
}
},
contentWindowInsets = scaffoldScrollableContentInsets,
)
}
@@ -110,6 +116,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,
@@ -130,6 +137,7 @@ private fun PinnedMessagesListContent(
state = state,
displayThreadSummaries = state.displayThreadSummaries,
onEventClick = onEventClick,
onGalleryItemClick = onGalleryItemClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
@@ -168,6 +176,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,
@@ -201,11 +210,12 @@ private fun PinnedMessagesListLoaded(
onEmojiReactionClick = { _, _ -> },
onVerifiedUserSendFailureClick = {}
)
LazyColumn(
modifier = modifier.fillMaxSize(),
state = rememberLazyListState(),
reverseLayout = true,
contentPadding = PaddingValues(vertical = 8.dp),
contentPadding = lazyColumnContentPadding + PaddingValues(vertical = 8.dp),
) {
items(
items = state.timelineItems,
@@ -216,7 +226,6 @@ private fun PinnedMessagesListLoaded(
timelineItem = timelineItem,
timelineMode = Timeline.Mode.PinnedEvents,
timelineRoomInfo = state.timelineRoomInfo,
renderReadReceipts = false,
timelineProtectionState = state.timelineProtectionState,
isLastOutgoingMessage = false,
focusedEventId = null,
@@ -226,6 +235,7 @@ private fun PinnedMessagesListLoaded(
},
onLinkLongClick = onLinkLongClick,
onContentClick = onEventClick,
onGalleryItemClick = onGalleryItemClick,
onLongClick = ::onMessageLongClick,
displayThreadSummaries = displayThreadSummaries,
inReplyToClick = {},
@@ -235,12 +245,18 @@ private fun PinnedMessagesListLoaded(
onReadReceiptClick = {},
onSwipeToReply = {},
onJoinCallClick = {},
eventSink = {},
eventSink = { timelineItemEvent ->
when (timelineItemEvent) {
is TimelineEvent.OpenThread -> state.eventSink(PinnedMessagesListEvent.OpenThread(timelineItemEvent.threadRootEventId))
else -> Unit
}
},
eventContentView = { event, contentModifier, onContentLayoutChange ->
TimelineItemEventContentViewWrapper(
event = event,
timelineProtectionState = state.timelineProtectionState,
onContentClick = { onEventClick(event) },
onGalleryItemClick = { index -> onGalleryItemClick(event, index) },
onLongClick = { onMessageLongClick(event) },
onLinkClick = { link ->
state.linkState.eventSink(LinkEvent.OnLinkClick(link))
@@ -264,6 +280,7 @@ private fun TimelineItemEventContentViewWrapper(
event: TimelineItem.Event,
timelineProtectionState: TimelineProtectionState,
onContentClick: () -> Unit,
onGalleryItemClick: (index: Int) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onLongClick: (() -> Unit)?,
@@ -278,9 +295,10 @@ private fun TimelineItemEventContentViewWrapper(
)
} else {
TimelineItemEventContentView(
eventId = event.eventId,
content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
timelineProtectionState = timelineProtectionState,
onGalleryItemClick = onGalleryItemClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
eventSink = { },
@@ -300,6 +318,7 @@ internal fun PinnedMessagesListViewPreview(@PreviewParameter(PinnedMessagesListS
state = state,
onBackClick = {},
onEventClick = { },
onGalleryItemClick = { _, _ -> },
onUserDataClick = {},
onLinkClick = {},
onLinkLongClick = {},
@@ -67,7 +67,9 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.alias.matches
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.matrix.ui.model.getBestName
import io.element.android.libraries.ui.utils.a11y.hasExternalKeyboard
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.collections.immutable.ImmutableList
@@ -86,7 +88,6 @@ class ThreadedMessagesNode(
private val presenterFactory: MessagesPresenter.Factory,
private val actionListPresenterFactory: ActionListPresenter.Factory,
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val mediaPlayer: MediaPlayer,
private val permalinkParser: PermalinkParser,
private val appNavigationStateService: AppNavigationStateService,
private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
@@ -112,7 +113,7 @@ class ThreadedMessagesNode(
this.timelineController = timelineController
return presenterFactory.create(
navigator = this,
composerPresenter = messageComposerPresenterFactory.create(timelineController, this),
composerPresenter = messageComposerPresenterFactory.create(timelineController, this, threadRoot = inputs.threadRootEventId),
timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this),
// TODO add special processor for threaded timeline
actionListPresenter = actionListPresenterFactory.create(
@@ -124,7 +125,8 @@ class ThreadedMessagesNode(
}
interface Callback : Plugin {
fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean
fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean
fun handleGalleryItemClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, galleryItemIndex: Int, canUseOverlay: Boolean): Boolean
fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
fun navigateToRoomMemberDetails(userId: UserId)
fun handlePermalinkClick(data: PermalinkData)
@@ -134,8 +136,12 @@ class ThreadedMessagesNode(
fun navigateToSendLocation()
fun navigateToCreatePoll()
fun navigateToEditPoll(eventId: EventId)
fun navigateToCurrentLiveLocation()
fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean)
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
fun navigateToDeveloperSettings()
fun navigateToAvatarPreview(username: String, avatarUrl: String)
}
override fun onBuilt() {
@@ -153,9 +159,6 @@ class ThreadedMessagesNode(
onStop = {
appNavigationStateService.onLeavingThread(id)
},
onDestroy = {
mediaPlayer.close()
}
)
}
@@ -233,16 +236,30 @@ class ThreadedMessagesNode(
callback.handlePermalinkClick(permalinkData)
}
override fun navigateToMember(userId: UserId) {
callback.navigateToRoomMemberDetails(userId)
}
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
callback.navigateToThread(threadRootId, focusedEventId)
}
override fun navigateToDeveloperSettings() {
callback.navigateToDeveloperSettings()
}
override fun navigateToCurrentLiveLocation() {
// Shouldn't happen because LiveLocationSharingBanner is not shown in threads.
callback.navigateToCurrentLiveLocation()
}
override fun close() = navigateUp()
@Composable
override fun View(modifier: Modifier) {
val activity = requireNotNull(LocalActivity.current)
val isDark = ElementTheme.isLightTheme.not()
val canUseOverlay = !isTalkbackActive() && !hasExternalKeyboard()
CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
) {
@@ -262,11 +279,25 @@ class ThreadedMessagesNode(
onEventContentClick = { isLive, event ->
timelineController?.let { controller ->
if (isLive) {
callback.handleEventClick(controller.mainTimelineMode(), event)
callback.handleEventClick(controller.mainTimelineMode(), event, canUseOverlay)
} else {
val detachedTimelineMode = controller.detachedTimelineMode()
if (detachedTimelineMode != null) {
callback.handleEventClick(detachedTimelineMode, event)
callback.handleEventClick(detachedTimelineMode, event, canUseOverlay)
} else {
false
}
}
} == true
},
onGalleryEventItemClick = { isLive, event, index ->
timelineController?.let { controller ->
if (isLive) {
callback.handleGalleryItemClick(controller.mainTimelineMode(), event, index, canUseOverlay)
} else {
val detachedTimelineMode = controller.detachedTimelineMode()
if (detachedTimelineMode != null) {
callback.handleGalleryItemClick(detachedTimelineMode, event, index, canUseOverlay)
} else {
false
}
@@ -291,6 +322,8 @@ class ThreadedMessagesNode(
onViewAllPinnedMessagesClick = {},
modifier = modifier,
knockRequestsBannerView = {},
customReactionBottomSheet = {},
onThreadsListClick = {},
)
roomMemberModerationRenderer.Render(
@@ -301,6 +334,11 @@ class ThreadedMessagesNode(
else -> state.roomMemberModerationState.eventSink(RoomMemberModerationEvents.ProcessAction(action, target))
}
},
onAvatarClick = { user ->
user.avatarUrl?.let { url ->
callback.navigateToAvatarPreview(user.getBestName(), url)
}
},
modifier = Modifier,
)
@@ -0,0 +1,17 @@
/*
* 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.threads.list
import io.element.android.libraries.matrix.api.room.threads.ThreadListItem
data class ThreadListRowItem(
val item: ThreadListItem,
val rootEventText: String?,
val latestEventText: String?,
val formattedTimestamp: String,
)
@@ -0,0 +1,44 @@
/*
* 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.threads.list
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.ThreadId
@ContributesNode(RoomScope::class)
@AssistedInject
class ThreadsListNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: ThreadsListPresenter,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun openThread(threadId: ThreadId)
}
private val callback: Callback = callback()
@Composable
override fun View(modifier: Modifier) {
ThreadsListView(
state = presenter.present(),
modifier = modifier,
onThreadClick = callback::openThread,
onBackClick = this::navigateUp,
)
}
}
@@ -0,0 +1,145 @@
/*
* 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.threads.list
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Inject
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFactory
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus
import io.element.android.libraries.matrix.ui.model.getAvatarData
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import timber.log.Timber
@Inject
class ThreadsListPresenter(
private val room: JoinedRoom,
private val timelineItemContentFactory: TimelineItemContentFactory,
private val messageSummaryFormatter: MessageSummaryFormatter,
private val dateFormatter: DateFormatter,
) : Presenter<ThreadsListState> {
@Composable
override fun present(): ThreadsListState {
val coroutineScope = rememberCoroutineScope()
val threadsListService = room.threadsListService
val threads by produceState(initialValue = persistentListOf(), key1 = threadsListService) {
threadsListService.subscribeToItemUpdates()
.onStart { threadsListService.paginate() }
.collect { items ->
Timber.d("Received thread list update with ${items.size} items")
value = items.map { item ->
val rootTimelineEvent = item.rootEvent.content?.let {
timelineItemContentFactory.create(
itemContent = it,
eventId = item.rootEvent.eventId,
isEditable = false,
sender = item.rootEvent.senderId,
senderProfile = item.rootEvent.senderProfile,
)
}
val rootEventText = rootTimelineEvent?.let { messageSummaryFormatter.format(it) }
val latestTimelineEvent = item.latestEvent?.content?.let {
timelineItemContentFactory.create(
itemContent = it,
eventId = item.latestEvent!!.eventId,
isEditable = false,
sender = item.latestEvent!!.senderId,
senderProfile = item.latestEvent!!.senderProfile,
)
}
val latestEventText = latestTimelineEvent?.let { messageSummaryFormatter.format(it) }
val formattedTimestamp = dateFormatter.format(
timestamp = item.latestEvent?.timestamp ?: item.rootEvent.timestamp,
mode = DateFormatterMode.TimeOrDate,
useRelative = true,
)
ThreadListRowItem(
item = item,
rootEventText = rootEventText,
latestEventText = latestEventText,
formattedTimestamp = formattedTimestamp,
)
}.toImmutableList()
}
}
val paginationStatus by produceState<ThreadListPaginationStatus>(
initialValue = ThreadListPaginationStatus.Idle(hasMoreToLoad = true),
key1 = threadsListService
) {
threadsListService
.subscribeToPaginationUpdates()
.collect { value = it }
}
val roomInfo by room.roomInfoFlow.collectAsState()
val heroes by remember {
derivedStateOf { roomInfo.heroes.map { it.getAvatarData(AvatarSize.CurrentUserTopBar) }.toImmutableList() }
}
fun handleEvent(event: ThreadsListEvents) {
when (event) {
ThreadsListEvents.Paginate -> if ((paginationStatus as? ThreadListPaginationStatus.Idle)?.hasMoreToLoad == true) {
coroutineScope.launch {
Timber.d("Paginating thread list: $paginationStatus")
threadsListService.paginate()
.onFailure { Timber.w(it, "Failed to paginate thread list") }
}
} else {
Timber.d("Not paginating, current status: $paginationStatus")
}
}
}
return ThreadsListState(
threads = threads,
roomId = room.roomId,
roomName = roomInfo.name ?: room.roomId.value,
roomAvatarUrl = roomInfo.avatarUrl,
isRoomTombstoned = roomInfo.successorRoom != null,
heroes = heroes,
eventSink = ::handleEvent,
)
}
}
data class ThreadsListState(
val roomId: RoomId,
val roomName: String,
val roomAvatarUrl: String?,
val isRoomTombstoned: Boolean,
val heroes: ImmutableList<AvatarData>,
val threads: ImmutableList<ThreadListRowItem>,
val eventSink: (ThreadsListEvents) -> Unit,
)
sealed interface ThreadsListEvents {
data object Paginate : ThreadsListEvents
}
@@ -0,0 +1,412 @@
/*
* 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.threads.list
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.plus
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ROOM_NAME
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
import io.element.android.libraries.matrix.api.core.RoomId
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.core.asEventId
import io.element.android.libraries.matrix.api.room.threads.ThreadListItem
import io.element.android.libraries.matrix.api.room.threads.ThreadListItemEvent
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ThreadsListView(
state: ThreadsListState,
onThreadClick: (ThreadId) -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {
val description = stringResource(
CommonStrings.a11y_threads_in_room,
state.roomName,
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clearAndSetSemantics {
heading()
contentDescription = description
},
) {
Avatar(
avatarData = AvatarData(
id = state.roomId.value,
name = state.roomName,
url = state.roomAvatarUrl,
size = AvatarSize.CurrentUserTopBar,
),
avatarType = AvatarType.Room(
heroes = state.heroes,
isTombstoned = state.isRoomTombstoned,
),
contentDescription = null,
)
Column {
Text(
text = stringResource(CommonStrings.common_threads),
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = state.roomName,
style = ElementTheme.typography.fontBodyXsRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
},
navigationIcon = {
BackButton(onBackClick)
},
windowInsets = scaffoldScrollableContentInsets,
)
}
) { padding ->
val lazyListState = rememberLazyListState()
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = padding + lazyColumnContentPadding,
state = lazyListState,
) {
itemsIndexed(state.threads, key = { _, row -> row.item.threadId }) { index, row ->
ThreadListItemRow(
threadItem = row,
onClick = onThreadClick,
)
if (index < state.threads.size - 1) {
HorizontalDivider()
}
}
}
ScrollHelper(lazyListState) {
state.eventSink(ThreadsListEvents.Paginate)
}
}
}
@Composable
private fun ScrollHelper(
listState: LazyListState,
onPaginate: () -> Unit,
) {
val updatedOnPaginate by rememberUpdatedState(onPaginate)
val lastVisibleItemIndex by remember {
derivedStateOf { listState.firstVisibleItemIndex + listState.layoutInfo.visibleItemsInfo.size - 1 }
}
val shouldPaginate by remember {
derivedStateOf {
val canLoadNewItems = listState.isScrollInProgress || listState.layoutInfo.totalItemsCount == 0
canLoadNewItems && lastVisibleItemIndex >= listState.layoutInfo.totalItemsCount - 1
}
}
LaunchedEffect(shouldPaginate, lastVisibleItemIndex) {
if (shouldPaginate) {
updatedOnPaginate()
delay(400L)
}
}
}
@Composable
private fun ThreadListItemRow(
threadItem: ThreadListRowItem,
onClick: (ThreadId) -> Unit,
) {
Row(
modifier = Modifier
.clickable { onClick(threadItem.item.threadId) }
.fillMaxWidth()
.padding(top = 4.dp, bottom = 8.dp, start = 16.dp, end = 16.dp),
) {
val rootEvent = threadItem.item.rootEvent
val senderProfile = rootEvent.senderProfile
Avatar(
modifier = Modifier.align(Alignment.CenterVertically),
avatarData = AvatarData(
id = rootEvent.senderId.value,
name = senderProfile.getDisambiguatedDisplayName(rootEvent.senderId),
url = senderProfile.getAvatarUrl(),
size = AvatarSize.ThreadsListItem,
),
avatarType = AvatarType.User,
contentDescription = null,
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.fillMaxWidth()) {
// TODO actually compute these values based on the thread state (not available yet)
val hasMentions = false
val hasUnreadNotifications = false
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
modifier = Modifier.weight(1f),
text = senderProfile.getDisambiguatedDisplayName(rootEvent.senderId),
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = threadItem.formattedTimestamp,
style = ElementTheme.typography.fontBodySmRegular,
color = if (hasUnreadNotifications || hasMentions) ElementTheme.colors.textActionAccent else ElementTheme.colors.textSecondary,
)
}
Spacer(modifier = Modifier.height(2.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier.weight(1f),
text = threadItem.rootEventText.orEmpty(),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(7.dp)
) {
if (hasMentions) {
Icon(
modifier = Modifier.size(14.dp),
imageVector = CompoundIcons.Mention(),
contentDescription = null,
tint = ElementTheme.colors.textActionAccent,
)
}
UnreadIndicatorAtom(
size = 14.dp,
isVisible = hasUnreadNotifications,
)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "${threadItem.item.numberOfReplies}",
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.width(4.dp))
Icon(
modifier = Modifier.size(20.dp),
imageVector = CompoundIcons.ThreadsSolid(),
contentDescription = null,
tint = ElementTheme.colors.iconSecondary
)
Spacer(modifier = Modifier.width(8.dp))
threadItem.item.latestEvent?.let { latestEvent ->
Avatar(
avatarData = AvatarData(
id = latestEvent.senderId.value,
name = latestEvent.senderProfile.getDisambiguatedDisplayName(latestEvent.senderId),
url = latestEvent.senderProfile.getAvatarUrl(),
size = AvatarSize.TimelineThreadLatestEventSender,
),
avatarType = AvatarType.User,
contentDescription = null,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = threadItem.latestEventText.orEmpty(),
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
@PreviewsDayNight
@Composable
internal fun ThreadsListViewPreview() {
ElementPreview {
ThreadsListView(
state = ThreadsListState(
roomId = RoomId("!room-id:server"),
roomName = ROOM_NAME,
roomAvatarUrl = null,
heroes = persistentListOf(),
threads = List(10) { aThreadListRowItem(threadId = ThreadId("\$thread-$it")) }.toImmutableList(),
isRoomTombstoned = false,
eventSink = {},
),
onThreadClick = {},
onBackClick = {},
)
}
}
@PreviewsDayNight
@Composable
internal fun ThreadListItemRowPreview() {
ElementPreview {
ThreadListItemRow(
threadItem = aThreadListRowItem(),
onClick = {},
)
}
}
fun aThreadListRowItem(
threadId: ThreadId = ThreadId("\$a-thread-id"),
rootEvent: ThreadListItemEvent = aThreadListItemEvent(threadId = threadId),
latestEvent: ThreadListItemEvent? = aThreadListItemEvent(threadId = threadId),
numberOfReplies: Long = 42,
rootEventText: String? = "Hello world!",
latestEventText: String? = "Hello again!",
formattedTimestamp: String = "12:34",
) = ThreadListRowItem(
item = aThreadListItem(
threadId = threadId,
rootEvent = rootEvent,
latestEvent = latestEvent,
numberOfReplies = numberOfReplies,
),
rootEventText = rootEventText,
latestEventText = latestEventText,
formattedTimestamp = formattedTimestamp,
)
fun aThreadListItem(
threadId: ThreadId = ThreadId("\$a-thread-id"),
rootEvent: ThreadListItemEvent = aThreadListItemEvent(threadId = threadId),
latestEvent: ThreadListItemEvent? = aThreadListItemEvent(threadId = threadId),
numberOfReplies: Long = 42,
) = ThreadListItem(
rootEvent = rootEvent,
latestEvent = latestEvent,
numberOfReplies = numberOfReplies,
)
fun aThreadListItemEvent(
threadId: ThreadId = ThreadId("\$a-thread-id"),
senderId: UserId = UserId("@a-user-id:server"),
senderProfile: ProfileDetails = ProfileDetails.Ready(
displayName = USER_NAME_ALICE,
displayNameAmbiguous = false,
avatarUrl = null,
displayedStatus = null,
),
isOwn: Boolean = false,
content: EventContent = MessageContent(
body = "Hello world!",
inReplyTo = null,
isEdited = false,
threadInfo = null,
type = TextMessageType("Hello world!", null),
),
timestamp: Long = 0L,
) = ThreadListItemEvent(
eventId = threadId.asEventId(),
senderId = senderId,
senderProfile = senderProfile,
isOwn = isOwn,
content = content,
timestamp = timestamp,
)
@@ -13,7 +13,9 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
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
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import kotlin.time.Duration
sealed interface TimelineEvent {
@@ -25,6 +27,8 @@ sealed interface TimelineEvent {
data object HideShieldDialog : TimelineEvent
data object MarkAllAsRead : TimelineEvent
/**
* Events coming from a timeline item.
*/
@@ -57,4 +61,12 @@ sealed interface TimelineEvent {
data class EditPoll(
val pollStartId: EventId,
) : TimelineItemPollEvent
data object StopLiveLocationShare : TimelineItemEvent
data class ValidateMedia(
val eventId: EventId,
val mediaSources: List<MediaSource>,
val validationState: ContentValidationState
) : TimelineItemEvent
}
@@ -23,6 +23,7 @@ import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.UserEventPermissions
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureEvent
@@ -32,7 +33,10 @@ import io.element.android.features.messages.impl.timeline.factories.TimelineItem
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.userEventPermissions
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
@@ -48,7 +52,6 @@ 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.core.asEventId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.ReceiptType
@@ -63,9 +66,11 @@ import io.element.android.services.analytics.api.finishLongRunningTransaction
import io.element.android.services.analyticsproviders.api.AnalyticsUserData
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -94,6 +99,9 @@ class TimelinePresenter(
private val roomCallStatePresenter: Presenter<RoomCallState>,
private val featureFlagService: FeatureFlagService,
private val analyticsService: AnalyticsService,
private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val markAsFullyRead: MarkAsFullyRead,
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
) : Presenter<TimelineState> {
private val tag = "TimelinePresenter"
@@ -132,16 +140,16 @@ class TimelinePresenter(
val prevMostRecentItemId = rememberSaveable { mutableStateOf<UniqueId?>(null) }
val newEventState = remember { mutableStateOf(NewEventState.None) }
val newEventState = remember { mutableStateOf<NewEventState>(NewEventState.None) }
val messageShieldDialogData: MutableState<MessageShieldData?> = remember { mutableStateOf(null) }
// Forces [JumpToUnreadState.Hidden] until the next RoomInfo push. Set after a
// [TimelineEvent.MarkAllAsRead] await completes so the FAB hides without waiting for
// the SDK to push a refreshed fully-read marker; the after-await ordering means any
// RoomInfo update racing the mark-as-read call has already landed and can't undo this.
val suppressJumpToUnread = remember { mutableStateOf(false) }
val resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailurePresenter.present()
val isSendPublicReadReceiptsEnabled by remember {
sessionPreferencesStore.isSendPublicReadReceiptsEnabled()
}.collectAsState(initial = true)
val renderReadReceipts by remember {
sessionPreferencesStore.isRenderReadReceiptsEnabled()
}.collectAsState(initial = true)
val isLive by remember {
timelineController.isLive()
}.collectAsState(initial = true)
@@ -149,6 +157,11 @@ class TimelinePresenter(
val displayThreadSummaries by produceState(false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.Threads)
}
val displayJumpToUnread by produceState(false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.JumpToUnread)
}
val timelineProtectionState = timelineProtectionPresenter.present()
fun handleEvent(event: TimelineEvent) {
when (event) {
@@ -167,12 +180,15 @@ class TimelinePresenter(
newEventState.value = NewEventState.None
}
Timber.tag(tag).d("## sendReadReceiptIfNeeded firstVisibleIndex: ${event.firstIndex}")
sessionCoroutineScope.sendReadReceiptIfNeeded(
firstVisibleIndex = event.firstIndex,
timelineItems = timelineItems,
lastReadReceiptId = lastReadReceiptId,
readReceiptType = if (isSendPublicReadReceiptsEnabled) ReceiptType.READ else ReceiptType.READ_PRIVATE,
)
sessionCoroutineScope.launch {
val sendPublicReadReceipts = sessionPreferencesStore.isSendPublicReadReceiptsEnabled().first()
sendReadReceiptIfNeeded(
firstVisibleIndex = event.firstIndex,
timelineItems = timelineItems,
lastReadReceiptId = lastReadReceiptId,
readReceiptType = if (sendPublicReadReceipts) ReceiptType.READ else ReceiptType.READ_PRIVATE,
)
}
} else {
newEventState.value = NewEventState.None
}
@@ -199,6 +215,9 @@ class TimelinePresenter(
is TimelineEvent.EditPoll -> {
navigator.navigateToEditPoll(event.pollStartId)
}
is TimelineEvent.StopLiveLocationShare -> sessionCoroutineScope.launch {
liveLocationShareManager.stopShare(room.roomId)
}
is TimelineEvent.FocusOnEvent -> sessionCoroutineScope.launch {
focusRequestState.value = FocusRequestState.Requested(event.eventId, event.debounce)
delay(event.debounce)
@@ -218,6 +237,14 @@ class TimelinePresenter(
timelineController.focusOnLive()
}
TimelineEvent.HideShieldDialog -> messageShieldDialogData.value = null
TimelineEvent.MarkAllAsRead -> sessionCoroutineScope.launch {
val latestEventId = room.liveTimeline.getLatestEventId().getOrElse {
Timber.tag(tag).w(it, "Failed to get latest event id to mark as fully read")
null
} ?: return@launch
markAsFullyRead(room.roomId, latestEventId)
suppressJumpToUnread.value = true
}
is TimelineEvent.ShowShieldDialog -> messageShieldDialogData.value = event.messageShieldData
is TimelineEvent.ComputeVerifiedUserSendFailure -> {
resolveVerifiedUserSendFailureState.eventSink(ResolveVerifiedUserSendFailureEvent.ComputeForMessage(event.event))
@@ -233,6 +260,9 @@ class TimelinePresenter(
focusedEventId = event.focusedEvent,
)
}
is TimelineEvent.ValidateMedia -> {
timelineProtectionState.eventSink(TimelineProtectionEvent.ValidateContent(event.mediaSources, event.validationState))
}
}
}
@@ -240,7 +270,7 @@ class TimelinePresenter(
timelineItemsFactory.timelineItems
.onEach { newTimelineItems ->
timelineItemIndexer.process(newTimelineItems)
timelineItems = newTimelineItems
timelineItems = newTimelineItems.toImmutableList()
analyticsService.run {
finishLongRunningTransaction(DisplayFirstTimelineItems)
@@ -249,13 +279,18 @@ class TimelinePresenter(
}
.launchIn(this)
combine(timelineController.timelineItems(), room.membersStateFlow) { items, membersState ->
combine(
timelineController.timelineItems(),
room.membersStateFlow,
sessionPreferencesStore.isRenderReadReceiptsEnabled(),
) { items, membersState, renderReadReceipts ->
val parent = analyticsService.getLongRunningTransaction(DisplayFirstTimelineItems)
val transaction = parent?.startChild("timelineItemsFactory.replaceWith", "Processing timeline items")
transaction?.putExtraData(AnalyticsUserData.TIMELINE_ITEM_COUNT, items.count().toString())
timelineItemsFactory.replaceWith(
timelineItems = items,
roomMembers = membersState.roomMembers().orEmpty()
roomMembers = membersState.roomMembers().orEmpty(),
renderReadReceipts = renderReadReceipts,
)
transaction?.finish()
items
@@ -269,7 +304,67 @@ class TimelinePresenter(
computeNewItemState(timelineItems, prevMostRecentItemId, newEventState)
}
LaunchedEffect(timelineItems.size, focusRequestState.value) {
// Keyed on the full [timelineItems] reference (not just .size) so we re-scan when the
// read marker advances in place — the SDK swaps the marker virtual item to a new position
// without changing the list length, e.g. when [markRoomAsFullyRead] is sent while at the
// bottom of the room.
//
// The state has three shapes:
// - InWindow: the SDK has materialised a virtual ReadMarker item in the loaded window;
// tapping the FAB smoothly scrolls to its index.
// - OutOfWindow: the marker event is older than the loaded window, so the SDK gives us
// only the event id via RoomInfo.fullyReadEventId; tapping triggers a focused-event
// load via the existing TimelineEvent.FocusOnEvent path.
// - Hidden: feature flag off, no marker, caught-up (marker loaded but no virtual item),
// or initial load (no items yet).
val jumpToUnread = remember { mutableStateOf<JumpToUnreadState>(JumpToUnreadState.Hidden) }
// The SDK is authoritative again once it pushes a new fully-read marker, so drop the
// post-mark-as-read suppression and let the recompute below pick up the new value.
LaunchedEffect(roomInfo.fullyReadEventId) {
suppressJumpToUnread.value = false
}
LaunchedEffect(
timelineItems.map { it.identifier() },
displayJumpToUnread,
roomInfo.fullyReadEventId,
roomInfo.numUnreadMessages,
suppressJumpToUnread.value,
) {
if (!displayJumpToUnread || suppressJumpToUnread.value) {
jumpToUnread.value = JumpToUnreadState.Hidden
return@LaunchedEffect
}
val items = timelineItems
val fullyReadEventId = roomInfo.fullyReadEventId
val hasUnreadMessages = roomInfo.numUnreadMessages > 0
val markerIndex = withContext(dispatchers.computation) {
items.indexOfFirst {
(it as? TimelineItem.Virtual)?.model is TimelineItemReadMarkerModel
}
}
jumpToUnread.value = when {
markerIndex >= 0 -> JumpToUnreadState.InWindow(markerIndex)
// Out-of-window only when there is genuinely unread *displayable* content
// (numUnreadMessages counts "interesting" messages, never state/hidden events) AND
// the marker event isn't merely an in-window item we don't render. isKnown is the
// cheap display-index check; isEventLoaded falls back to the SDK to tell
// "in window but not displayed" apart from "genuinely out of window".
fullyReadEventId != null &&
hasUnreadMessages &&
items.isNotEmpty() &&
!timelineItemIndexer.isKnown(fullyReadEventId) &&
!timelineController.activeTimelineFlow().value.isEventLoaded(fullyReadEventId) ->
JumpToUnreadState.OutOfWindow(fullyReadEventId)
else -> JumpToUnreadState.Hidden
}
}
// Keyed on the full [timelineItems] reference (not just .size) so we re-resolve the index
// when a focused timeline loads with the same item count as the window it replaced — e.g.
// jumping to an out-of-window read marker in a busy room, where both windows fill to the
// same page size. With .size as the key the effect wouldn't re-run, the focused event's
// index would stay unresolved, and the scroll would never fire until a second tap.
LaunchedEffect(timelineItems.map { it.identifier() }, focusRequestState.value) {
val currentFocusRequestState = focusRequestState.value
if (currentFocusRequestState is FocusRequestState.Success && !currentFocusRequestState.rendered) {
val eventId = currentFocusRequestState.eventId
@@ -310,13 +405,14 @@ class TimelinePresenter(
timelineItems = timelineItems,
timelineMode = timelineMode,
timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts,
newEventState = newEventState.value,
isLive = isLive,
focusRequestState = focusRequestState.value,
messageShieldDialogData = messageShieldDialogData.value,
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
displayThreadSummaries = displayThreadSummaries,
displayJumpToUnread = displayJumpToUnread,
jumpToUnread = jumpToUnread.value,
eventSink = ::handleEvent,
)
}
@@ -378,7 +474,7 @@ class TimelinePresenter(
private suspend fun computeNewItemState(
timelineItems: ImmutableList<TimelineItem>,
prevMostRecentItemId: MutableState<UniqueId?>,
newEventState: MutableState<NewEventState>
newEventState: MutableState<NewEventState>,
) = withContext(dispatchers.computation) {
// FromMe is prioritized over FromOther, so skip if we already have a FromMe
if (newEventState.value == NewEventState.FromMe) {
@@ -397,12 +493,7 @@ class TimelinePresenter(
if (hasNewEvent) {
// Scroll to bottom if the new event is from me, even if sent from another device
val fromMe = newMostRecentItem.isMine
newEventState.value = if (fromMe) {
NewEventState.FromMe
} else {
NewEventState.FromOther
}
newEventState.value = if (newMostRecentItem.isMine) NewEventState.FromMe else NewEventState.FromOther
}
prevMostRecentItemId.value = newMostRecentItemId
}
@@ -26,7 +26,6 @@ data class TimelineState(
val timelineItems: ImmutableList<TimelineItem>,
val timelineRoomInfo: TimelineRoomInfo,
val timelineMode: Timeline.Mode,
val renderReadReceipts: Boolean,
val newEventState: NewEventState,
val isLive: Boolean,
val focusRequestState: FocusRequestState,
@@ -34,6 +33,8 @@ data class TimelineState(
val messageShieldDialogData: MessageShieldData?,
val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState,
val displayThreadSummaries: Boolean,
val displayJumpToUnread: Boolean,
val jumpToUnread: JumpToUnreadState,
val eventSink: (TimelineEvent) -> Unit,
) {
private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event
@@ -82,3 +83,18 @@ data class TimelineRoomInfo(
val typingNotificationState: TypingNotificationState,
val predecessorRoom: PredecessorRoom?,
)
/**
* Whether the jump-to-unread FAB should be shown, and if so, how tapping it
* should bring the user to the read marker.
*/
@Immutable
sealed interface JumpToUnreadState {
data object Hidden : JumpToUnreadState
/** The read marker is materialised at [index] in the loaded timeline — smooth scroll to it. */
data class InWindow(val index: Int) : JumpToUnreadState
/** The read marker event is older than the loaded window — load it via focused-event navigation. */
data class OutOfWindow(val eventId: EventId) : JumpToUnreadState
}
@@ -21,6 +21,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemRead
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.anAggregatedReaction
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
@@ -29,6 +30,8 @@ import io.element.android.features.messages.impl.typing.aTypingNotificationState
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ROOM_NAME
import io.element.android.libraries.designsystem.preview.USER_NAME_SENDER
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UniqueId
@@ -49,13 +52,15 @@ import kotlin.random.Random
fun aTimelineState(
timelineItems: ImmutableList<TimelineItem> = persistentListOf(),
timelineMode: Timeline.Mode = Timeline.Mode.Live,
renderReadReceipts: Boolean = false,
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
focusedEventIndex: Int = -1,
isLive: Boolean = true,
messageShield: MessageShield? = null,
resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(),
displayThreadSummaries: Boolean = false,
displayJumpToUnread: Boolean = false,
jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden,
newEventState: NewEventState = NewEventState.None,
eventSink: (TimelineEvent) -> Unit = {},
): TimelineState {
val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId
@@ -68,13 +73,14 @@ fun aTimelineState(
timelineItems = timelineItems,
timelineMode = timelineMode,
timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts,
newEventState = NewEventState.None,
newEventState = newEventState,
isLive = isLive,
focusRequestState = focusRequestState,
messageShieldDialogData = messageShield?.let { MessageShieldData(it) },
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
displayThreadSummaries = displayThreadSummaries,
displayJumpToUnread = displayJumpToUnread,
jumpToUnread = jumpToUnread,
eventSink = eventSink,
)
}
@@ -141,7 +147,7 @@ internal fun aTimelineItemEvent(
isMine: Boolean = false,
isEditable: Boolean = false,
canBeRepliedTo: Boolean = false,
senderDisplayName: String = "Sender",
senderDisplayName: String = USER_NAME_SENDER,
displayNameAmbiguous: Boolean = false,
content: TimelineItemEventContent = aTimelineItemTextContent(),
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
@@ -158,7 +164,7 @@ internal fun aTimelineItemEvent(
eventId = eventId,
transactionId = transactionId,
senderId = UserId("@senderId:domain"),
senderAvatar = AvatarData("@senderId:domain", "sender", size = AvatarSize.TimelineSender),
senderAvatar = AvatarData("@senderId:domain", USER_NAME_SENDER, size = AvatarSize.TimelineSender),
content = content,
reactionsState = timelineItemReactions,
readReceiptState = readReceiptState,
@@ -250,8 +256,25 @@ internal fun aGroupedEvents(
)
}
internal fun aRedactedMessagesGroupedEvents(
id: UniqueId = UniqueId("redacted_group"),
count: Int = 4,
): TimelineItem.GroupedEvents {
val events = (0 until count).map { index ->
aTimelineItemEvent(
eventId = EventId("\$redacted_$index"),
content = TimelineItemRedactedContent,
)
}
return TimelineItem.GroupedEvents(
id = id,
events = events.toImmutableList(),
aggregatedReadReceipts = persistentListOf(),
)
}
internal fun aTimelineRoomInfo(
name: String = "Room name",
name: String = ROOM_NAME,
isDm: Boolean = false,
userHasPermissionToSendMessage: Boolean = true,
pinnedEventIds: List<EventId> = emptyList(),
@@ -10,22 +10,40 @@ package io.element.android.features.messages.impl.timeline
import android.view.HapticFeedbackConstants
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.plus
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
@@ -39,18 +57,31 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureView
import io.element.android.features.messages.impl.timeline.components.FloatingDateBadgeOverlay
import io.element.android.features.messages.impl.timeline.components.TimelineItemRow
import io.element.android.features.messages.impl.timeline.components.toText
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
@@ -63,25 +94,29 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.libraries.androidutils.system.copyToClipboard
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.components.dialogs.AlertDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
import io.element.android.libraries.designsystem.text.roundToPx
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.animateScrollToItemCenter
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.testtags.TestTag
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.wysiwyg.link.Link
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.launch
import timber.log.Timber
@@ -94,6 +129,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,
@@ -104,7 +140,9 @@ fun TimelineView(
modifier: Modifier = Modifier,
lazyListState: LazyListState = rememberLazyListState(),
forceJumpToBottomVisibility: Boolean = false,
forceJumpToReadMarkerVisibility: Boolean = false,
nestedScrollConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(),
floatingDateTopOffset: Dp = 0.dp,
) {
fun clearFocusRequestState() {
state.eventSink(TimelineEvent.ClearFocusRequestState)
@@ -122,12 +160,17 @@ fun TimelineView(
state.eventSink(TimelineEvent.JumpToLive)
}
fun onMarkAllAsRead() {
state.eventSink(TimelineEvent.MarkAllAsRead)
}
fun onFocusOnEvent(eventId: EventId) {
state.eventSink(TimelineEvent.FocusOnEvent(eventId))
}
val context = LocalContext.current
val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard)
val view = LocalView.current
// Disable reverse layout when TalkBack is enabled to avoid incorrect ordering issues seen in the current Compose UI version
val useReverseLayout = !isTalkbackActive()
fun inReplyToClick(eventId: EventId) {
state.eventSink(TimelineEvent.FocusOnEvent(eventId))
}
@@ -155,8 +198,8 @@ fun TimelineView(
.nestedScroll(nestedScrollConnection)
.testTag(TestTags.timeline),
state = lazyListState,
reverseLayout = useReverseLayout,
contentPadding = PaddingValues(top = 64.dp, bottom = 8.dp),
reverseLayout = true,
contentPadding = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal).asPaddingValues() + PaddingValues(top = 64.dp, bottom = 8.dp),
) {
items(
items = state.timelineItems,
@@ -168,7 +211,6 @@ fun TimelineView(
timelineMode = state.timelineMode,
timelineRoomInfo = state.timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
renderReadReceipts = state.renderReadReceipts,
isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()),
focusedEventId = state.focusedEventId,
displayThreadSummaries = state.displayThreadSummaries,
@@ -176,6 +218,7 @@ fun TimelineView(
onLinkClick = onLinkClick,
onLinkLongClick = ::onLinkLongClick,
onContentClick = onContentClick,
onGalleryItemClick = onGalleryItemClick,
onLongClick = onMessageLongClick,
inReplyToClick = ::inReplyToClick,
onReactionClick = onReactionClick,
@@ -203,12 +246,24 @@ fun TimelineView(
hasAnyEvent = state.hasAnyEvent,
lazyListState = lazyListState,
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
forceJumpToReadMarkerVisibility = forceJumpToReadMarkerVisibility,
newEventState = state.newEventState,
isLive = state.isLive,
focusRequestState = state.focusRequestState,
displayJumpToUnread = state.displayJumpToUnread,
jumpToUnread = state.jumpToUnread,
onScrollFinishAt = ::onScrollFinishAt,
onJumpToLive = ::onJumpToLive,
onFocusEventRender = ::onFocusEventRender,
onMarkAllAsRead = ::onMarkAllAsRead,
onFocusOnEvent = ::onFocusOnEvent,
)
FloatingDateBadgeOverlay(
lazyListState = lazyListState,
timelineItems = state.timelineItems,
isLive = state.isLive,
topOffset = floatingDateTopOffset,
)
}
}
@@ -250,11 +305,16 @@ private fun TimelinePrefetchingHelper(
firstVisibleItemIndex + layoutInfo.visibleItemsInfo.size >= layoutInfo.totalItemsCount - 40
}
// If we have no timeline items, we need to back paginate to load some messages. This usually happens on all timelines except for live ones.
// This automatic pagination was previously done by the SDK, and we received a `Reset` update, but now we need to do it ourselves.
val isEmptyTimelineFlow = layoutInfoFlow.map { it.totalItemsCount == 0 }
combine(
isCloseToStartOfLoadedTimelineFlow.distinctUntilChanged(),
isScrollingFlow.distinctUntilChanged(),
) { needsPrefetch, isScrolling ->
needsPrefetch && isScrolling
isEmptyTimelineFlow,
) { needsPrefetch, isScrolling, isEmptyAndNeedsBackPagination ->
isEmptyAndNeedsBackPagination || needsPrefetch && isScrolling
}
.distinctUntilChanged()
.collectLatest { needsPrefetch ->
@@ -273,10 +333,15 @@ private fun BoxScope.TimelineScrollHelper(
newEventState: NewEventState,
isLive: Boolean,
forceJumpToBottomVisibility: Boolean,
forceJumpToReadMarkerVisibility: Boolean,
focusRequestState: FocusRequestState,
displayJumpToUnread: Boolean,
jumpToUnread: JumpToUnreadState,
onScrollFinishAt: (Int) -> Unit,
onJumpToLive: () -> Unit,
onFocusEventRender: () -> Unit,
onMarkAllAsRead: () -> Unit,
onFocusOnEvent: (EventId) -> Unit,
) {
val coroutineScope = rememberCoroutineScope()
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
@@ -285,6 +350,22 @@ private fun BoxScope.TimelineScrollHelper(
lazyListState.firstVisibleItemIndex < 3 && isLive
}
}
val isJumpToUnreadVisible by remember(jumpToUnread, forceJumpToReadMarkerVisibility) {
derivedStateOf {
if (forceJumpToReadMarkerVisibility) return@derivedStateOf true
when (val jtu = jumpToUnread) {
JumpToUnreadState.Hidden -> false
// Marker is outside the loaded window — we have no on-screen anchor, so just show.
is JumpToUnreadState.OutOfWindow -> true
// Marker is in the loaded window — hide once it's scrolled into the visible range.
is JumpToUnreadState.InWindow -> {
val lastVisibleIndex = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: return@derivedStateOf false
jtu.index > lastVisibleIndex
}
}
}
}
val isJumpToBottomVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive
var jumpToLiveHandled by remember { mutableStateOf(true) }
/**
@@ -311,6 +392,16 @@ private fun BoxScope.TimelineScrollHelper(
}
}
fun jumpToReadMarker() {
when (val jtu = jumpToUnread) {
JumpToUnreadState.Hidden -> Unit
is JumpToUnreadState.InWindow -> coroutineScope.launch {
lazyListState.animateScrollToItemCenter(jtu.index)
}
is JumpToUnreadState.OutOfWindow -> onFocusOnEvent(jtu.eventId)
}
}
LaunchedEffect(jumpToLiveHandled, isLive) {
if (!jumpToLiveHandled && isLive) {
lazyListState.scrollToItem(0)
@@ -327,8 +418,11 @@ private fun BoxScope.TimelineScrollHelper(
}
LaunchedEffect(canAutoScroll, newEventState) {
val shouldScrollToBottom = isScrollFinished &&
(canAutoScroll && newEventState == NewEventState.FromOther || newEventState == NewEventState.FromMe)
val shouldScrollToBottom = isScrollFinished && when (newEventState) {
is NewEventState.FromOther -> canAutoScroll
NewEventState.FromMe -> true
NewEventState.None -> false
}
if (shouldScrollToBottom) {
scrollToBottom(force = true)
}
@@ -342,43 +436,145 @@ private fun BoxScope.TimelineScrollHelper(
}
}
JumpToBottomButton(
// Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered
isVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive,
Column(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 12.dp),
onClick = { jumpToBottom() },
)
.padding(end = 24.dp, bottom = 16.dp)
) {
JumpToPositionButton(
icon = CompoundIcons.ChevronUp(),
contentDescription = stringResource(id = CommonStrings.a11y_jump_to_unread_messages),
modifier = Modifier.padding(bottom = 12.dp),
isVisible = isJumpToUnreadVisible,
hasUnread = true,
onClick = ::jumpToReadMarker,
onMarkAsRead = onMarkAllAsRead,
testTag = TestTags.jumpToUnreadButton,
)
// Reserves space for the jump to bottom button so the jump to unread button above
// stays in the same position regardless of whether the jump to bottom button is visible.
Box(modifier = Modifier.size(36.dp)) {
JumpToPositionButton(
icon = CompoundIcons.ChevronDown(),
contentDescription = stringResource(id = CommonStrings.a11y_jump_to_bottom),
isVisible = isJumpToBottomVisible,
hasUnread = displayJumpToUnread && newEventState is NewEventState.FromOther,
onClick = ::jumpToBottom,
onMarkAsRead = onMarkAllAsRead,
testTag = TestTags.jumpToBottomButton,
dotAlignment = Alignment.BottomCenter,
)
}
}
}
@Composable
private fun JumpToBottomButton(
private fun JumpToPositionButton(
icon: ImageVector,
contentDescription: String,
isVisible: Boolean,
hasUnread: Boolean,
onClick: () -> Unit,
onMarkAsRead: () -> Unit,
testTag: TestTag,
modifier: Modifier = Modifier,
dotAlignment: Alignment = Alignment.TopCenter,
) {
AnimatedVisibility(
modifier = modifier,
visible = isVisible,
enter = scaleIn(animationSpec = tween(100)),
exit = scaleOut(animationSpec = tween(100)),
enter = scaleIn(animationSpec = tween(220), initialScale = 0.8f) + fadeIn(animationSpec = tween(220)),
exit = scaleOut(animationSpec = tween(180), targetScale = 0.8f) + fadeOut(animationSpec = tween(180)),
) {
FloatingActionButton(
onClick = onClick,
elevation = FloatingActionButtonDefaults.elevation(4.dp, 4.dp, 4.dp, 4.dp),
shape = CircleShape,
modifier = Modifier.size(36.dp),
containerColor = ElementTheme.colors.bgSubtleSecondary,
contentColor = ElementTheme.colors.iconSecondary,
) {
Icon(
var menuExpanded by remember { mutableStateOf(false) }
Box {
Box(
modifier = Modifier
.size(24.dp)
.rotate(90f),
imageVector = CompoundIcons.ArrowRight(),
contentDescription = stringResource(id = CommonStrings.a11y_jump_to_bottom)
)
.size(36.dp)
.background(color = ElementTheme.colors.bgCanvasDefault, shape = CircleShape)
.clip(CircleShape)
.border(1.dp, ElementTheme.colors.borderDisabled, CircleShape)
.combinedClickable(
onClick = onClick,
onLongClick = { menuExpanded = true },
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
)
.testTag(testTag),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(24.dp),
imageVector = icon,
contentDescription = contentDescription,
tint = ElementTheme.colors.iconSecondary,
)
val menuTransitionState = remember { MutableTransitionState(false) }
.apply { targetState = menuExpanded }
if (menuTransitionState.currentState || menuTransitionState.targetState) {
val gapPx = with(LocalDensity.current) { 8.dp.roundToPx() }
val positionProvider = remember(gapPx) { CenterStartOfAnchorPositionProvider(gapPx) }
Popup(
popupPositionProvider = positionProvider,
onDismissRequest = { menuExpanded = false },
properties = PopupProperties(focusable = true),
) {
// Anchor the scale to the right-center edge so the menu visually grows
// outward from the FAB it's attached to.
val transformOrigin = TransformOrigin(pivotFractionX = 1f, pivotFractionY = 0.5f)
AnimatedVisibility(
visibleState = menuTransitionState,
enter = scaleIn(
animationSpec = tween(180),
initialScale = 0.9f,
transformOrigin = transformOrigin,
) + fadeIn(animationSpec = tween(180)),
exit = scaleOut(
animationSpec = tween(140),
targetScale = 0.9f,
transformOrigin = transformOrigin,
) + fadeOut(animationSpec = tween(140)),
) {
// Hand-rolled instead of DropdownMenuItem: padding here is tighter
// than DropdownMenuItem's 12.dp default to match the Figma spec.
Row(
modifier = Modifier
.shadow(elevation = 1.dp, shape = RoundedCornerShape(8.dp))
.clip(RoundedCornerShape(8.dp))
.background(ElementTheme.colors.bgCanvasDefaultLevel1)
.border(1.dp, ElementTheme.colors.borderDisabled, RoundedCornerShape(8.dp))
.clickable {
menuExpanded = false
onMarkAsRead()
}
.padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = CompoundIcons.MarkAsRead(),
contentDescription = null,
tint = ElementTheme.colors.iconTertiary,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(id = CommonStrings.action_mark_as_read),
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontBodyLgRegular,
)
}
}
}
}
}
val dotYOffset = if (dotAlignment == Alignment.BottomCenter) 4.dp else (-4).dp
if (hasUnread) {
UnreadIndicatorAtom(
modifier = Modifier
.align(dotAlignment)
.offset { IntOffset(x = 0, y = dotYOffset.roundToPx()) },
color = ElementTheme.colors.iconSuccessPrimary,
border = BorderStroke(2.dp, ElementTheme.colors.bgCanvasDefault),
)
}
}
}
}
@@ -411,10 +607,93 @@ internal fun TimelineViewPreview(
onSwipeToReply = {},
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onJoinCallClick = {},
onMoreReactionsClick = {},
onReadReceiptClick = {},
onJoinCallClick = {},
onGalleryItemClick = { _, _ -> },
forceJumpToBottomVisibility = true,
)
}
}
// The jump-to-unread FAB's indicator is always rendered when the FAB is visible — in
// production the FAB only appears when unread content exists above. So `hasUnreadAbove`
// here only varies the scroll-target state, not the upper indicator's visibility.
@Composable
private fun TimelineViewWithReadMarker(
hasUnreadAbove: Boolean,
hasUnreadBelow: Boolean,
) {
val timelineItems = persistentListOf<TimelineItem>(
aTimelineItemEvent(isMine = false),
aTimelineItemEvent(isMine = false),
aTimelineItemEvent(isMine = true),
aTimelineItemEvent(isMine = false),
aTimelineItemEvent(isMine = false),
aTimelineItemEvent(isMine = false),
)
CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(),
) {
TimelineView(
state = aTimelineState(
timelineItems = timelineItems,
displayJumpToUnread = true,
// Index points past the loaded items, mirroring the real-world state the FAB
// represents: the user has scrolled past the read marker, so it's no longer in
// view. The actual scroll target doesn't matter for a static preview.
jumpToUnread = if (hasUnreadAbove) JumpToUnreadState.InWindow(timelineItems.size) else JumpToUnreadState.Hidden,
newEventState = if (hasUnreadBelow) NewEventState.FromOther else NewEventState.None,
),
timelineProtectionState = aTimelineProtectionState(),
onUserDataClick = {},
onLinkClick = {},
onContentClick = {},
onGalleryItemClick = { _, _ -> },
onMessageLongClick = {},
onSwipeToReply = {},
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
forceJumpToBottomVisibility = true,
forceJumpToReadMarkerVisibility = true,
onJoinCallClick = { },
)
}
}
@PreviewsDayNight
@Composable
internal fun TimelineViewWithReadMarkerJumpToUnreadIndicatorOnlyPreview() = ElementPreview {
TimelineViewWithReadMarker(hasUnreadAbove = false, hasUnreadBelow = false)
}
@PreviewsDayNight
@Composable
internal fun TimelineViewWithReadMarkerBothIndicatorsPreview() = ElementPreview {
TimelineViewWithReadMarker(hasUnreadAbove = true, hasUnreadBelow = true)
}
/**
* Anchors the popup so its right edge sits [gapPx] to the left of the anchor and its vertical
* center matches the anchor's. Adapts to localized menu widths and FAB size; coerced to stay
* on-screen.
*/
private class CenterStartOfAnchorPositionProvider(
private val gapPx: Int,
) : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize,
): IntOffset {
val x = anchorBounds.left - popupContentSize.width - gapPx
val y = anchorBounds.top + (anchorBounds.height - popupContentSize.height) / 2
return IntOffset(
x = x.coerceIn(0, (windowSize.width - popupContentSize.width).coerceAtLeast(0)),
y = y.coerceIn(0, (windowSize.height - popupContentSize.height).coerceAtLeast(0)),
)
}
}
@@ -50,6 +50,7 @@ internal fun TimelineViewMessageShieldPreview() = ElementPreview {
onMoreReactionsClick = {},
onReadReceiptClick = {},
onJoinCallClick = {},
onGalleryItemClick = { _, _ -> },
forceJumpToBottomVisibility = true,
)
}
@@ -22,7 +22,6 @@ internal fun ATimelineItemEventRow(
event: TimelineItem.Event,
timelineMode: Timeline.Mode = Timeline.Mode.Live,
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
renderReadReceipts: Boolean = false,
isLastOutgoingMessage: Boolean = false,
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
displayThreadSummaries: Boolean = false,
@@ -30,11 +29,11 @@ internal fun ATimelineItemEventRow(
event = event,
timelineMode = timelineMode,
timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts,
timelineProtectionState = timelineProtectionState,
isLastOutgoingMessage = isLastOutgoingMessage,
displayThreadSummaries = displayThreadSummaries,
onEventClick = {},
onGalleryItemClick = {},
onLongClick = {},
onLinkClick = {},
onLinkLongClick = {},
@@ -0,0 +1,378 @@
/*
* 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.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
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.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReadReceipts
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
import io.element.android.libraries.dateformatter.api.toHumanReadableDuration
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarRow
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.designsystem.preview.USER_NAME_BOB
import io.element.android.libraries.designsystem.preview.USER_NAME_CAROL
import io.element.android.libraries.designsystem.preview.USER_NAME_CHARLIE
import io.element.android.libraries.designsystem.preview.USER_NAME_DAVID
import io.element.android.libraries.designsystem.preview.USER_NAME_JOHN_DOE
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.seconds
@Composable
internal fun ActiveCallTimelineItemView(
timelineRoomInfo: TimelineRoomInfo,
event: TimelineItem.Event,
state: RtcNotificationState.Active,
isLastOutgoingMessage: Boolean,
onLongClick: (TimelineItem.Event) -> Unit,
onReadReceiptsClick: (TimelineItem.Event) -> Unit,
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min)
.padding(start = 16.dp, end = 16.dp, top = 16.dp)
.border(1.dp, ElementTheme.colors.borderInteractivePrimary, RoundedCornerShape(8.dp))
.combinedClickable(
enabled = true,
onClick = {},
onLongClick = { onLongClick(event) },
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
)
.onKeyboardContextMenuAction { onLongClick(event) }
.padding(start = 12.dp, end = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier
.padding(top = 12.dp, bottom = 12.dp)
.size(40.dp)
.background(ElementTheme.colors.bgSubtleSecondary, RoundedCornerShape(8.dp))
.padding(10.dp),
imageVector = if (state.callIntent == CallIntent.AUDIO) CompoundIcons.VoiceCallSolid() else CompoundIcons.VideoCallSolid(),
contentDescription = null,
tint = ElementTheme.colors.iconPrimary,
)
if (timelineRoomInfo.isDm) {
DirectMessageCallBody(state, Modifier.weight(1f))
} else {
GroupCallBody(
event,
state,
Modifier
.weight(1f)
.padding(top = 12.dp, bottom = 12.dp)
)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(top = 12.dp, bottom = 12.dp)
) {
if (!state.isJoined) {
Button(
text = stringResource(CommonStrings.action_join),
size = ButtonSize.Small,
onClick = { onJoinCallClick(state.callIntent == CallIntent.AUDIO) }
)
}
if (state.callStartTsMillis != null) {
CallDurationText(
state.callStartTsMillis,
)
}
}
}
TimelineItemReadReceiptView(
state = ReadReceiptViewState(
sendState = event.localSendState,
isLastOutgoingMessage = isLastOutgoingMessage,
receipts = event.readReceiptState.receipts,
),
onReadReceiptsClick = { onReadReceiptsClick(event) },
modifier = Modifier.padding(top = 4.dp),
)
}
}
@Composable
private fun DirectMessageCallBody(state: RtcNotificationState.Active, modifier: Modifier = Modifier) {
Row(
verticalAlignment = Alignment.Top,
modifier = modifier
) {
if (state.joinedMembers.size == 1) {
val caller = state.joinedMembers.first()
Avatar(
avatarData = AvatarData(
id = caller.userId.value,
name = caller.displayName,
url = caller.avatarUrl,
size = AvatarSize.ActiveCallItem,
),
avatarType = AvatarType.User
)
Text(
modifier = Modifier.padding(start = 6.dp),
text = stringResource(CommonStrings.common_user_started_a_call, caller.displayName ?: caller.userId),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
)
} else {
AvatarRow(
avatarDataList = state.joinedMembers
.map { user ->
AvatarData(
id = user.userId.value,
name = user.displayName,
url = user.avatarUrl,
size = AvatarSize.ActiveCallItem
)
}
.toImmutableList(),
avatarType = AvatarType.User,
overlapRatio = 0.5f,
lastOnTop = true,
)
Text(
modifier = Modifier.padding(start = 6.dp),
text = stringResource(CommonStrings.common_call_in_progress),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
)
}
}
}
@Composable
private fun GroupCallBody(event: TimelineItem.Event, state: RtcNotificationState.Active, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
Text(
text = stringResource(CommonStrings.common_group_call_in_progress),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
verticalAlignment = Alignment.CenterVertically
) {
if (state.joinedMembers.size == 1) {
val caller = state.joinedMembers.first()
Avatar(
avatarData = AvatarData(
id = caller.userId.value,
name = caller.displayName,
url = caller.avatarUrl,
size = AvatarSize.ActiveCallItem,
),
avatarType = AvatarType.User
)
// Only show started a call if the last remaining user in the call
// is really the one who started the call
if (caller.userId == event.senderId) {
Text(
modifier = Modifier.padding(start = 6.dp),
text = stringResource(CommonStrings.common_user_started_a_call, caller.displayName ?: caller.userId),
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
}
} else {
val displayMembers = state.joinedMembers.take(3)
AvatarRow(
avatarDataList = displayMembers
.map { user ->
AvatarData(
id = user.userId.value,
name = user.displayName,
url = user.avatarUrl,
size = AvatarSize.ActiveCallItem
)
}
.toImmutableList(),
avatarType = AvatarType.User,
overlapRatio = 0.5f,
lastOnTop = true,
)
Text(
text = pluralStringResource(
CommonPlurals.screen_timeline_active_call_joined_count,
count = state.joinedMembers.size,
state.joinedMembers.size
),
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
}
}
@Composable
private fun CallDurationText(
startTsMillis: Long,
modifier: Modifier = Modifier,
) {
fun formatElapsed(startTsMillis: Long): String =
(System.currentTimeMillis() - startTsMillis).coerceAtLeast(0).toHumanReadableDuration()
val duration by produceState(
initialValue = formatElapsed(startTsMillis),
startTsMillis,
) {
while (true) {
delay(1.seconds)
value = formatElapsed(startTsMillis)
}
}
Text(
modifier = modifier,
text = "($duration)",
style = ElementTheme.typography.fontBodyXsRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@PreviewsDayNight
@PreviewWithLargeHeight
@Composable
internal fun ActiveCallTimelineItemViewPreview() = ElementPreview(
drawableFallbackForImages = CommonDrawables.sample_avatar
) {
val readReceiptState = mutableListOf(
aTimelineItemReadReceipts(
receipts = List(3) { aReadReceiptData(it) },
)
)
Column(modifier = Modifier.padding(bottom = 16.dp)) {
listOf(CallIntent.AUDIO, CallIntent.VIDEO).forEach { callIntent ->
listOf(
RtcNotificationState.Active(
listOf(aMatrixUser(displayName = USER_NAME_ALICE, avatarUrl = "anUrl")),
isJoined = true,
callStartTsMillis = System.currentTimeMillis() - 60_000,
callIntent = callIntent
) to true,
RtcNotificationState.Active(
listOf(
aMatrixUser(displayName = USER_NAME_ALICE, avatarUrl = "anUrl"),
aMatrixUser(displayName = USER_NAME_BOB)
),
isJoined = true,
callStartTsMillis = System.currentTimeMillis() - 60_000,
callIntent = callIntent
) to true,
RtcNotificationState.Active(
listOf(
aMatrixUser(displayName = USER_NAME_ALICE),
aMatrixUser(displayName = USER_NAME_BOB, avatarUrl = "anUrlB"),
aMatrixUser(displayName = USER_NAME_CAROL),
aMatrixUser(displayName = USER_NAME_DAVID),
aMatrixUser(displayName = USER_NAME_CHARLIE),
aMatrixUser(displayName = USER_NAME_JOHN_DOE, avatarUrl = "anUrlC"),
),
isJoined = false,
callStartTsMillis = System.currentTimeMillis(),
callIntent = callIntent
) to false,
// Group call only one member still in call, but it is not the creator
RtcNotificationState.Active(
listOf(
aMatrixUser(displayName = USER_NAME_ALICE, avatarUrl = "aliceURl"),
),
isJoined = true,
callStartTsMillis = System.currentTimeMillis(),
callIntent = callIntent
) to false,
// Group call only one member that is the call creator
RtcNotificationState.Active(
listOf(
aMatrixUser(
id = "@senderId:domain",
displayName = "Sender"
),
),
isJoined = true,
callStartTsMillis = System.currentTimeMillis(),
callIntent = callIntent
) to false,
).forEach { (state, isDm) ->
val content = TimelineItemRtcNotificationContent(callIntent, state)
ActiveCallTimelineItemView(
timelineRoomInfo = aTimelineRoomInfo(isDm = isDm),
event = aTimelineItemEvent(
content = content,
// Only display read receipts for the first item
readReceiptState = readReceiptState.removeFirstOrNull() ?: aTimelineItemReadReceipts(),
),
state,
isLastOutgoingMessage = false,
onLongClick = {},
onReadReceiptsClick = {},
onJoinCallClick = {},
)
}
}
}
}
@@ -11,5 +11,6 @@ package io.element.android.features.messages.impl.timeline.components
enum class ContentPadding {
Textual,
Media,
CaptionedMedia
CaptionedMedia,
InvalidContent,
}
@@ -0,0 +1,149 @@
/*
* Copyright (c) 2025 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.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.floatingDateBadgeBackground
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlin.time.Duration.Companion.milliseconds
@Composable
internal fun BoxScope.FloatingDateBadgeOverlay(
lazyListState: LazyListState,
timelineItems: ImmutableList<TimelineItem>,
isLive: Boolean,
topOffset: Dp = 0.dp,
) {
// This needs to be a state to trigger a `derivedState` recalculation
val updatedTimelineItems by rememberUpdatedState(timelineItems)
// Look for the last visible item with a timestamp, starting from the last visible item and going backwards until we find one or reach the start of the list
val lastVisibleItemWithTimestamp by remember {
derivedStateOf {
var index = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: return@derivedStateOf null
while (index >= 0) {
when (val item = updatedTimelineItems.getOrNull(index)) {
is TimelineItem.Event -> return@derivedStateOf item
is TimelineItem.Virtual -> if (item.model is TimelineItemDaySeparatorModel) return@derivedStateOf item
is TimelineItem.GroupedEvents -> return@derivedStateOf item.events.firstOrNull()
null -> Unit
}
index--
}
null
}
}
// Store the formatted date so we recompute it lazily and can keep it around even if we need to dispose the badge because the timeline items changed
var formattedDate: String? by remember { mutableStateOf(null) }
// Update the formatted date when we have a new non-null timestamp
LaunchedEffect(lastVisibleItemWithTimestamp) {
lastVisibleItemWithTimestamp?.formattedDate()?.let { formattedDate = it }
}
val isAtBottom by remember {
derivedStateOf {
lazyListState.firstVisibleItemIndex < 3 && isLive
}
}
var isBadgeVisible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
snapshotFlow { lazyListState.isScrollInProgress }
.collectLatest { isScrolling ->
if (isScrolling) {
isBadgeVisible = true
} else {
delay(2000.milliseconds)
isBadgeVisible = false
}
}
}
val showBadge = isBadgeVisible && !isAtBottom && formattedDate != null
AnimatedVisibility(
visible = showBadge,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 8.dp + topOffset)
.clearAndSetSemantics {
hideFromAccessibility()
},
enter = fadeIn(animationSpec = tween(150)),
exit = fadeOut(animationSpec = tween(300)),
) {
formattedDate?.let { dateText ->
FloatingDateBadge(
modifier = Modifier.padding(8.dp),
dateText = dateText,
)
}
}
}
@Composable
internal fun FloatingDateBadge(
dateText: String,
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 = dateText,
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
)
}
}
@PreviewsDayNight
@Composable
internal fun FloatingDateBadgePreview() = ElementPreview {
Box(modifier = Modifier.padding(16.dp)) {
FloatingDateBadge(dateText = "March 9, 2026")
}
}
@@ -19,7 +19,9 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
@@ -27,6 +29,8 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.layer.CompositingStrategy
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.LayoutDirection
@@ -47,8 +51,8 @@ import io.element.android.libraries.designsystem.theme.messageFromMeBackground
import io.element.android.libraries.designsystem.theme.messageFromOtherBackground
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.libraries.ui.utils.graphics.drawInLayer
import io.element.android.libraries.ui.utils.time.isTalkbackActive
private val BUBBLE_RADIUS = 12.dp
private val avatarRadius = AvatarSize.TimelineSender.dp / 2
@@ -62,6 +66,8 @@ fun MessageEventBubble(
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
customBackgroundColor: Color? = null,
borderColor: Color? = null,
content: @Composable BoxScope.() -> Unit = {},
) {
val clickableModifier = if (isTalkbackActive()) {
@@ -79,10 +85,12 @@ fun MessageEventBubble(
val cutTopStart = state.cutTopStart
// Ignore state.isHighlighted for now, we need a design decision on it.
val backgroundBubbleColor = MessageEventBubbleDefaults.backgroundBubbleColor(state.isMine)
val backgroundBubbleColor by rememberUpdatedState(customBackgroundColor ?: MessageEventBubbleDefaults.backgroundBubbleColor(state.isMine))
val bubbleShape = remember(state) { MessageEventBubbleDefaults.shape(state.cutTopStart, state.groupPosition, state.isMine) }
val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx()
val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx()
val updatedBorderColor by rememberUpdatedState(borderColor)
BoxWithConstraints(
modifier = modifier
.drawWithCache {
@@ -103,6 +111,9 @@ fun MessageEventBubble(
// Then draw the content on top of it
drawContent()
// Draw border color, if any
updatedBorderColor?.let { drawOutline(outline, it, style = Stroke(width = 1.dp.toPx())) }
// And then clip the top start corner if needed to make room for the avatar
if (cutTopStart) {
drawCircle(
@@ -26,6 +26,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.R
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId
@@ -162,9 +163,10 @@ internal fun MessageShieldViewPreview() {
MessageShield.AuthenticityNotGuaranteed(false),
forwarder = UserId("@alice:example.com"),
forwarderProfile = ProfileDetails.Ready(
displayName = "Alice",
displayName = USER_NAME_ALICE,
displayNameAmbiguous = false,
avatarUrl = null,
displayedStatus = null,
),
)
)
@@ -15,9 +15,12 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@@ -40,6 +43,7 @@ fun TimelineEventTimestampView(
event: TimelineItem.Event,
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
modifier: Modifier = Modifier,
isLayoutDirectionMismatched: Boolean = false,
) {
val formattedTime = event.sentTime
val hasError = event.failedToSend
@@ -47,9 +51,46 @@ fun TimelineEventTimestampView(
val isMessageEdited = event.content.isEdited()
val isMessageRedacted = event.content.isRedacted()
val tint = if (hasError || hasEncryptionCritical && !isMessageRedacted) ElementTheme.colors.textCriticalPrimary else ElementTheme.colors.textSecondary
val shield = event.messageShield
val isVerifiedUserSendFailure = event.localSendState is LocalEventSendState.Failed.VerifiedUser
val onClickLabel = when {
shield != null -> stringResource(CommonStrings.a11y_view_details)
hasError && isVerifiedUserSendFailure -> stringResource(CommonStrings.action_open_context_menu)
else -> null
}
val clickableModifier = remember(shield, hasError) {
when {
shield != null -> {
Modifier.clickable(
onClickLabel = onClickLabel,
) {
eventSink(TimelineEvent.ShowShieldDialog(shield))
}
}
hasError -> Modifier
.clickable(
enabled = isVerifiedUserSendFailure,
onClickLabel = onClickLabel,
) {
eventSink(TimelineEvent.ComputeVerifiedUserSendFailure(event))
}
else -> Modifier
}
}
val padding = if (!isLayoutDirectionMismatched) {
PaddingValues(start = TimelineEventTimestampViewDefaults.spacing)
} else {
PaddingValues(end = TimelineEventTimestampViewDefaults.spacing)
}
Row(
modifier = Modifier
.padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing))
.padding(padding)
// For a better click target, make the corners rounded
.clip(RoundedCornerShape(8.dp))
.then(clickableModifier)
.then(modifier),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -67,36 +108,22 @@ fun TimelineEventTimestampView(
color = tint,
)
if (hasError) {
val isVerifiedUserSendFailure = event.localSendState is LocalEventSendState.Failed.VerifiedUser
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = CompoundIcons.ErrorSolid(),
contentDescription = stringResource(id = CommonStrings.common_sending_failed),
tint = tint,
modifier = Modifier
.size(15.dp, 18.dp)
.clickable(
enabled = isVerifiedUserSendFailure,
onClickLabel = stringResource(CommonStrings.action_open_context_menu),
) {
eventSink(TimelineEvent.ComputeVerifiedUserSendFailure(event))
}
modifier = Modifier.size(15.dp, 18.dp),
)
}
if (!isMessageRedacted) {
event.messageShield?.let { shield ->
shield?.let { shield ->
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = shield.toIcon(),
contentDescription = stringResource(id = CommonStrings.a11y_encryption_details),
modifier = Modifier
.size(15.dp)
.clickable(
onClickLabel = stringResource(CommonStrings.a11y_view_details),
) {
eventSink(TimelineEvent.ShowShieldDialog(shield))
},
modifier = Modifier.size(15.dp),
tint = shield.toIconColor(),
)
Spacer(modifier = Modifier.width(4.dp))
@@ -8,6 +8,7 @@
package io.element.android.features.messages.impl.timeline.components
import androidx.annotation.StringRes
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
@@ -22,84 +23,74 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReadReceipts
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomcall.api.RoomCallStateProvider
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun TimelineItemCallNotifyView(
timelineRoomInfo: TimelineRoomInfo,
event: TimelineItem.Event,
roomCallState: RoomCallState,
content: TimelineItemRtcNotificationContent,
state: RtcNotificationState.Tombstoned,
isLastOutgoingMessage: Boolean,
onLongClick: (TimelineItem.Event) -> Unit,
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
onReadReceiptsClick: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.border(1.dp, ElementTheme.colors.borderInteractiveSecondary, RoundedCornerShape(8.dp))
.combinedClickable(
enabled = true,
onClick = {},
onLongClick = { onLongClick(event) },
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
Column(modifier = modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 16.dp)
.border(1.dp, ElementTheme.colors.borderInteractiveSecondary, RoundedCornerShape(8.dp))
.combinedClickable(
enabled = true,
onClick = {},
onLongClick = { onLongClick(event) },
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
)
.onKeyboardContextMenuAction { onLongClick(event) }
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier.size(20.sp.toDp()),
imageVector = getIcon(timelineRoomInfo, content),
contentDescription = null,
tint = ElementTheme.colors.iconSecondary,
)
.onKeyboardContextMenuAction { onLongClick(event) }
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(
avatarData = event.senderAvatar,
avatarType = AvatarType.User,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = event.safeSenderName,
style = ElementTheme.typography.fontBodyLgMedium,
modifier = Modifier.weight(1f),
text = stringResource(getTextRes(timelineRoomInfo, state)),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier.size(20.sp.toDp()),
imageVector = CompoundIcons.VideoCallSolid(),
contentDescription = null,
tint = ElementTheme.colors.iconSecondary,
)
Text(
text = stringResource(CommonStrings.common_call_started),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (roomCallState is RoomCallState.OnGoing) {
CallMenuItem(
roomCallState = roomCallState,
onJoinCallClick = onJoinCallClick,
)
} else {
Text(
text = event.sentTime,
style = ElementTheme.typography.fontBodyMdRegular,
@@ -108,23 +99,81 @@ internal fun TimelineItemCallNotifyView(
overflow = TextOverflow.Ellipsis,
)
}
TimelineItemReadReceiptView(
state = ReadReceiptViewState(
sendState = event.localSendState,
isLastOutgoingMessage = isLastOutgoingMessage,
receipts = event.readReceiptState.receipts,
),
onReadReceiptsClick = { onReadReceiptsClick(event) },
modifier = Modifier.padding(top = 4.dp),
)
}
}
@StringRes
private fun getTextRes(
timelineRoomInfo: TimelineRoomInfo,
state: RtcNotificationState.Tombstoned
): Int = if (timelineRoomInfo.isDm) {
when (state) {
is RtcNotificationState.Declined -> {
if (state.byMe) CommonStrings.common_call_you_declined else CommonStrings.common_call_declined
}
else -> CommonStrings.common_call_started
}
} else {
// In Rooms, do not show declined info.
CommonStrings.common_call_started
}
@Composable
private fun getIcon(
timelineRoomInfo: TimelineRoomInfo,
content: TimelineItemRtcNotificationContent
): ImageVector {
val showAsDeclined = timelineRoomInfo.isDm && content.state is RtcNotificationState.Declined
val icon = if (showAsDeclined) {
if (content.callIntent == CallIntent.AUDIO) CompoundIcons.VoiceCallDeclinedSolid() else CompoundIcons.VideoCallDeclinedSolid()
} else {
if (content.callIntent == CallIntent.AUDIO) CompoundIcons.VoiceCallSolid() else CompoundIcons.VideoCallSolid()
}
return icon
}
@PreviewsDayNight
@Composable
internal fun TimelineItemCallNotifyViewPreview() = ElementPreview {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
RoomCallStateProvider()
.values
.filter { it !is RoomCallState.Unavailable }
.forEach { roomCallState ->
TimelineItemCallNotifyView(
event = aTimelineItemEvent(content = TimelineItemRtcNotificationContent()),
roomCallState = roomCallState,
onLongClick = {},
onJoinCallClick = {},
)
val readReceiptState = mutableListOf(
aTimelineItemReadReceipts(
receipts = List(3) { aReadReceiptData(it) },
)
)
Column(modifier = Modifier.padding(bottom = 16.dp)) {
listOf(false, true).forEach { isDm ->
listOf(CallIntent.AUDIO, CallIntent.VIDEO).forEach { callIntent ->
listOf(
RtcNotificationState.Started,
RtcNotificationState.Declined(byMe = false),
RtcNotificationState.Declined(byMe = true),
).forEach { state ->
val content = TimelineItemRtcNotificationContent(callIntent, state)
TimelineItemCallNotifyView(
timelineRoomInfo = aTimelineRoomInfo(isDm = isDm),
event = aTimelineItemEvent(
content = content,
// Only display read receipts for the first item
readReceiptState = readReceiptState.removeFirstOrNull() ?: aTimelineItemReadReceipts(),
),
content = content,
state,
isLastOutgoingMessage = false,
onLongClick = {},
onReadReceiptsClick = {},
)
}
}
}
}
}
@@ -0,0 +1,346 @@
/*
* 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.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.components.event.ElementTimelineItemPreview
import io.element.android.features.messages.impl.timeline.components.event.aGalleryItem
import io.element.android.features.messages.impl.timeline.components.event.aTimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
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.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache
import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
private val AN_EVENT_ID = EventId($$"$eventId")
@PreviewsDayNight
@Composable
internal fun TimelineItemImageViewScanningContentPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading)))
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
ATimelineItemEventRow(
event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemImageContent()),
)
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemStickerViewScanningContentPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading)))
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
ATimelineItemEventRow(
event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemStickerContent(aspectRatio = 1.5f)),
)
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemVideoViewScanningContentPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading)))
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
ATimelineItemEventRow(
event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemVideoContent()),
)
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemFileViewScanningContentPreview() {
ElementTimelineItemPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading)))
}
CompositionLocalProvider(
LocalEventContentValidationState provides cache
) {
ATimelineItemEventRow(event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemFileContent()))
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemAudioViewScanningContentPreview() {
ElementTimelineItemPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading)))
}
CompositionLocalProvider(
LocalEventContentValidationState provides cache
) {
ATimelineItemEventRow(event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemAudioContent()))
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemVoiceViewScanningContentPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading)))
}
ElementTimelineItemPreview {
CompositionLocalProvider(
LocalEventContentValidationState provides cache,
LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(),
) {
ATimelineItemEventRow(
event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemVoiceContent()),
)
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemScanningContentFailedPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Invalid)))
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
ATimelineItemEventRow(event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemImageContent()))
ATimelineItemEventRow(event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemImageContent(caption = "A caption")))
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemScanningContentNotFoundPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(
AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.UnrecoverableError(IllegalStateException("Not found")))
))
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
ATimelineItemEventRow(event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemImageContent()))
ATimelineItemEventRow(event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemImageContent(caption = "A caption")))
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemScanningContentWithInvalidRepliesPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Invalid)))
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
ATimelineItemEventRow(
event = aTimelineItemEvent(
content = aTimelineItemTextContent(),
inReplyTo = inReplyToInvalidContent(),
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
eventId = AN_EVENT_ID,
content = aTimelineItemImageContent(),
inReplyTo = inReplyToInvalidContent(),
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
eventId = AN_EVENT_ID,
content = aTimelineItemImageContent(),
inReplyTo = inReplyToTextContent(),
)
)
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemScanningContentWithRepliesFailedPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(
initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.UnrecoverableError(IllegalStateException("BOOM"))))
)
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
ATimelineItemEventRow(
event = aTimelineItemEvent(
content = aTimelineItemTextContent(),
inReplyTo = inReplyToInvalidContent(),
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
eventId = AN_EVENT_ID,
content = aTimelineItemImageContent(),
inReplyTo = inReplyToInvalidContent(),
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
eventId = AN_EVENT_ID,
content = aTimelineItemImageContent(),
inReplyTo = inReplyToTextContent(),
)
)
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemGalleryViewScanningContentFailedPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(
initial =
mapOf(
AN_EVENT_ID to DefaultContentValidationState(mapOf(
"invalid" to ContentValidationValue.Invalid,
"error" to ContentValidationValue.UnrecoverableError(IllegalStateException("BOOM")),
"" to ContentValidationValue.Valid
))
)
)
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
ATimelineItemEventRow(
event = aTimelineItemEvent(
eventId = AN_EVENT_ID,
content = aTimelineItemGalleryContent(
items = listOf(
aGalleryItem(),
aGalleryItem(mediaSource = MediaSource("invalid", "{}")),
aGalleryItem(),
aGalleryItem(mediaSource = MediaSource("invalid", "{}")),
aGalleryItem(mediaSource = MediaSource("error", "{}")),
aGalleryItem(mediaSource = MediaSource("invalid", "{}")),
aGalleryItem(),
)
)
)
)
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemAttachmentsViewScanningContentFailedPreview() = ElementPreview {
val cache = remember {
InMemoryEventContentValidationCache(
initial =
mapOf(
AN_EVENT_ID to DefaultContentValidationState(mapOf(
"invalid" to ContentValidationValue.Invalid,
"error" to ContentValidationValue.UnrecoverableError(IllegalStateException("BOOM")),
"" to ContentValidationValue.Valid
))
)
)
}
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
ATimelineItemEventRow(
event = aTimelineItemEvent(
eventId = AN_EVENT_ID,
content = aTimelineItemAttachmentsContent(
attachments = listOf(
anAttachmentItem(),
anAttachmentItem(mediaSource = MediaSource("invalid", "{}")),
anAttachmentItem(),
anAttachmentItem(),
anAttachmentItem(mediaSource = MediaSource("error", "{}")),
anAttachmentItem(mediaSource = MediaSource("invalid", "{}")),
)
)
)
)
}
}
private fun inReplyToInvalidContent(): InReplyToDetails.Ready = InReplyToDetails.Ready(
eventId = AN_EVENT_ID,
senderId = UserId("@sender:matrix.org"),
eventContent = MessageContent(
body = "A body",
inReplyTo = null,
isEdited = false,
threadInfo = null,
type = ImageMessageType(
filename = "A file",
caption = "A caption",
formattedCaption = null,
source = MediaSource("", ""),
info = null,
)
),
textContent = "A text content",
senderProfile = ProfileDetails.Ready(
displayName = "Sender",
displayNameAmbiguous = false,
avatarUrl = null,
displayedStatus = null
)
)
private fun inReplyToTextContent(): InReplyToDetails.Ready = InReplyToDetails.Ready(
eventId = EventId($$"$text_eventId"),
senderId = UserId("@sender:matrix.org"),
eventContent = MessageContent(
body = "A body",
inReplyTo = null,
isEdited = false,
threadInfo = null,
type = TextMessageType(
body = "A body",
formatted = null,
)
),
textContent = "A text content",
senderProfile = ProfileDetails.Ready(
displayName = "Sender",
displayNameAmbiguous = false,
avatarUrl = null,
displayedStatus = null
)
)
@@ -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.components.event.aGalleryItem
import io.element.android.features.messages.impl.timeline.components.event.aTimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.model.event.GalleryItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAttachmentsContent
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",
),
),
),
)
}
@@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.timeline.components
import android.annotation.SuppressLint
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
@@ -32,13 +33,18 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.movableContentOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
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.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.res.pluralStringResource
@@ -48,9 +54,12 @@ import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.traversalIndex
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.constraintlayout.compose.ConstrainScope
@@ -67,17 +76,21 @@ import io.element.android.features.messages.impl.timeline.components.receipt.Rea
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
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.TimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.mustBeProtected
import io.element.android.libraries.architecture.AsyncData
@@ -89,7 +102,9 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.modifiers.niceClickable
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewWithExtraLargeHeight
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.designsystem.swipe.SwipeableActionsState
import io.element.android.libraries.designsystem.swipe.rememberSwipeableActionsState
import io.element.android.libraries.designsystem.text.toPx
@@ -103,14 +118,19 @@ import io.element.android.libraries.matrix.api.timeline.item.EmbeddedEventInfo
import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
import io.element.android.libraries.matrix.api.timeline.item.event.mediaSources
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToView
import io.element.android.libraries.matrix.ui.messages.reply.content
import io.element.android.libraries.matrix.ui.messages.reply.eventId
import io.element.android.libraries.matrix.ui.messages.sender.SenderName
import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode
@@ -118,8 +138,10 @@ import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.libraries.ui.utils.text.detect
import io.element.android.wysiwyg.link.Link
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.roundToInt
@@ -139,10 +161,10 @@ fun TimelineItemEventRow(
timelineMode: Timeline.Mode,
timelineRoomInfo: TimelineRoomInfo,
timelineProtectionState: TimelineProtectionState,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
displayThreadSummaries: Boolean,
onEventClick: () -> Unit,
onGalleryItemClick: ((Int) -> Unit),
onLongClick: () -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
@@ -160,16 +182,17 @@ fun TimelineItemEventRow(
val onContentClick = onEventClick.takeUnless { event.isWholeContentClickable }
TimelineItemEventContentView(
eventId = event.eventId,
content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
timelineProtectionState = timelineProtectionState,
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,
)
},
) {
@@ -269,7 +292,9 @@ fun TimelineItemEventRow(
if (displayThreadSummaries && timelineMode !is Timeline.Mode.Thread && event.threadInfo is TimelineItemThreadInfo.ThreadRoot) {
ThreadSummaryView(
modifier = if (event.isMine) {
Modifier.align(Alignment.End).padding(end = 16.dp)
Modifier
.align(Alignment.End)
.padding(end = 16.dp)
} else {
if (timelineRoomInfo.isDm) Modifier else Modifier.padding(start = 16.dp)
}.padding(top = 2.dp),
@@ -291,7 +316,6 @@ fun TimelineItemEventRow(
isLastOutgoingMessage = isLastOutgoingMessage,
receipts = event.readReceiptState.receipts,
),
renderReadReceipts = renderReadReceipts,
onReadReceiptsClick = { onReadReceiptClick(event) },
modifier = Modifier.padding(top = 4.dp)
)
@@ -450,6 +474,23 @@ private fun TimelineItemEventRowContent(
)
}
val currentContentValidationState by rememberEventContentValidationState(eventId = event.eventId, needsValidation = event.content.isMedia)
.collectOverallState()
val needsInvalidContentCustomisations =
// Gallery events should not apply the custom bubble color, instead each item will apply some custom color if needed
event.content !is TimelineItemGalleryContent &&
event.content !is TimelineItemAttachmentsContent &&
currentContentValidationState.hasError() &&
event.content.isMedia
// If the event has a dangerous media content we need to set custom message bubble background and border colors
val themeColors = ElementTheme.colors
val (dangerousContentBubbleColor, borderColor) = remember(themeColors.isLight, needsInvalidContentCustomisations, event.content.type) {
val background = themeColors.bgCriticalSubtle.takeIf { needsInvalidContentCustomisations }
val border = themeColors.borderCriticalSubtle.takeIf { needsInvalidContentCustomisations }
background to border
}
// Message bubble
val bubbleState = BubbleState(
groupPosition = event.groupPosition,
@@ -476,6 +517,8 @@ private fun TimelineItemEventRowContent(
interactionSource = interactionSource,
onClick = onContentClick,
onLongClick = onLongClick,
customBackgroundColor = dangerousContentBubbleColor,
borderColor = borderColor,
) {
MessageEventBubbleContent(
event = event,
@@ -646,23 +689,44 @@ private fun MessageEventBubbleContent(
.padding(horizontal = 4.dp, vertical = 2.dp)
)
}
TimestampPosition.Aligned ->
ContentAvoidingLayout(
modifier = modifier,
// The spacing is negative to make the content overlap the empty space at the start of the timestamp
spacing = (-4).dp,
overlayOffset = DpOffset(0.dp, -1.dp),
shrinkContent = canShrinkContent,
content = { content(this::onContentLayoutChange) },
overlay = {
TimelineEventTimestampView(
event = event,
eventSink = eventSink,
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
)
TimestampPosition.Aligned -> @Composable {
val originalLayoutDirection = LocalLayoutDirection.current
// Detect if the direction of the text content (if any) does not match the layout direction, to place the content and timestamp correctly
val contentDirection = if (event.content is TimelineItemTextContent) {
remember(event.content.body) {
when (TextDirection.detect(event.content.body)) {
TextDirection.Ltr, TextDirection.ContentOrLtr -> LayoutDirection.Ltr
TextDirection.Rtl, TextDirection.ContentOrRtl -> LayoutDirection.Rtl
else -> originalLayoutDirection
}
}
)
} else {
originalLayoutDirection
}
CompositionLocalProvider(LocalLayoutDirection provides contentDirection) {
ContentAvoidingLayout(
modifier = modifier,
// The spacing is negative to make the content overlap the empty space at the start of the timestamp
spacing = (-4).dp,
overlayOffset = DpOffset(0.dp, -1.dp),
shrinkContent = canShrinkContent,
content = { content(this::onContentLayoutChange) },
overlay = {
// Use the original layout direction for the timestamp
CompositionLocalProvider(LocalLayoutDirection provides originalLayoutDirection) {
TimelineEventTimestampView(
event = event,
eventSink = eventSink,
isLayoutDirectionMismatched = originalLayoutDirection != contentDirection,
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
)
}
}
TimestampPosition.Below ->
Column(modifier) {
content {}
@@ -674,6 +738,7 @@ private fun MessageEventBubbleContent(
.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
TimestampPosition.Hidden -> Box(modifier) { content {} }
}
}
@@ -707,6 +772,7 @@ private fun MessageEventBubbleContent(
}
ContentPadding.CaptionedMedia ->
Modifier.padding(start = 8.dp, end = 8.dp, top = topPadding, bottom = 8.dp)
ContentPadding.InvalidContent -> Modifier.padding(top = topPadding, bottom = 8.dp)
}
val threadDecoration = @Composable {
@@ -730,10 +796,13 @@ private fun MessageEventBubbleContent(
}
val inReplyTo = @Composable { inReplyTo: InReplyToDetails ->
val currentContentValidationState by rememberEventContentValidationState(eventId = inReplyTo.eventId(), eventContent = inReplyTo.content())
.collectOverallState()
val topPadding = if (showThreadDecoration) 0.dp else 8.dp
val shape = RoundedCornerShape(6.dp)
val inReplyToModifier = Modifier
.padding(top = topPadding, start = 8.dp, end = 8.dp)
.clip(RoundedCornerShape(6.dp))
.clip(shape)
val talkbackCompatModifier = if (isTalkbackActive()) {
// Use z-index to make the replied to text being read after the message
@@ -742,11 +811,28 @@ private fun MessageEventBubbleContent(
} else {
inReplyToModifier.clickable(onClick = inReplyToClick)
}
InReplyToView(
inReplyTo = inReplyTo,
hideImage = timelineProtectionState.hideMediaContent(inReplyTo.eventId()),
modifier = talkbackCompatModifier,
)
val contentHasError = currentContentValidationState.hasError()
val borderColor = if (contentHasError) ElementTheme.colors.borderCriticalSubtle else ElementTheme.colors.separatorPrimary
val backgroundColor = if (contentHasError) ElementTheme.colors.bgCriticalSubtle else ElementTheme.colors.bgCanvasDefault
Box(
modifier = talkbackCompatModifier
.border(1.dp, borderColor, shape)
.background(backgroundColor, shape)
.padding(4.dp)
) {
val contentValidationState = rememberEventContentValidationState(eventId = inReplyTo.eventId(), eventContent = inReplyTo.content())
val updatedEventSink by rememberUpdatedState(eventSink)
LaunchedEffect(inReplyTo) {
val mediaSources = inReplyTo.content()?.mediaSources() ?: return@LaunchedEffect
updatedEventSink(TimelineEvent.ValidateMedia(inReplyTo.eventId(), mediaSources, contentValidationState))
}
InReplyToView(
inReplyTo = inReplyTo,
contentValidationValue = currentContentValidationState,
hideImage = timelineProtectionState.hideMediaContent(inReplyTo.eventId()),
)
}
}
if (inReplyToDetails != null) {
// Use SubComposeLayout only if necessary as it can have consequences on the performance.
@@ -763,20 +849,47 @@ private fun MessageEventBubbleContent(
}
}
val timestampPosition = when (event.content) {
is TimelineItemImageContent -> if (event.content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
is TimelineItemVideoContent -> if (event.content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
is TimelineItemStickerContent,
is TimelineItemLocationContent -> TimestampPosition.Overlay
is TimelineItemPollContent -> TimestampPosition.Below
else -> TimestampPosition.Default
val contentValidationState by rememberEventContentValidationState(eventId = event.eventId, needsValidation = event.content.isMedia).collectOverallState()
val needsInvalidContentLayout =
// Gallery events should not apply custom paddings or layout dispositions
event.content !is TimelineItemGalleryContent &&
event.content !is TimelineItemAttachmentsContent &&
contentValidationState.hasError()
val timestampPosition = if (needsInvalidContentLayout) {
// The invalid content view will be displayed in all these cases, independent of the event content
TimestampPosition.Aligned
} else {
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 -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Below
is TimelineItemStickerContent -> TimestampPosition.Overlay
is TimelineItemLocationContent -> {
val content = content.ensureActiveLiveLocation()
val shouldHide = content.mode is TimelineItemLocationContent.Mode.Live &&
content.mode.isActive &&
content.mode.isOwnUser
if (shouldHide) TimestampPosition.Hidden else TimestampPosition.Overlay
}
is TimelineItemPollContent -> TimestampPosition.Below
else -> TimestampPosition.Default
}
}
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 TimelineItemStickerContent,
is TimelineItemLocationContent -> ContentPadding.Media
else -> ContentPadding.Textual
val paddingBehaviour = if (needsInvalidContentLayout) {
ContentPadding.InvalidContent
} else {
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
}
}
CommonLayout(
showThreadDecoration = timelineMode !is Timeline.Mode.Thread && event.threadInfo is TimelineItemThreadInfo.ThreadResponse,
@@ -833,25 +946,29 @@ internal fun TimelineItemEventRowWithThreadSummaryPreview() = ElementPreview {
groupPosition = TimelineItemGroupPosition.First,
threadInfo = TimelineItemThreadInfo.ThreadRoot(
latestEventText = "This is the latest message in the thread",
summary = ThreadSummary(AsyncData.Success(
EmbeddedEventInfo(
eventOrTransactionId = EventOrTransactionId.Event(EventId("\$event-id")),
content = MessageContent(
body = "This is the latest message in the thread",
inReplyTo = null,
isEdited = false,
threadInfo = null,
type = TextMessageType("This is the latest message in the thread", null)
),
senderId = UserId("@user:id"),
senderProfile = ProfileDetails.Ready(
displayName = "Alice",
avatarUrl = null,
displayNameAmbiguous = false,
),
timestamp = 0L,
)
), numberOfReplies = 20L)
summary = ThreadSummary(
latestEvent = AsyncData.Success(
EmbeddedEventInfo(
eventOrTransactionId = EventOrTransactionId.Event(EventId("\$event-id")),
content = MessageContent(
body = "This is the latest message in the thread",
inReplyTo = null,
isEdited = false,
threadInfo = null,
type = TextMessageType("This is the latest message in the thread", null)
),
senderId = UserId("@user:id"),
senderProfile = ProfileDetails.Ready(
displayName = USER_NAME_ALICE,
avatarUrl = null,
displayNameAmbiguous = false,
displayedStatus = null,
),
timestamp = 0L,
)
),
numberOfReplies = 20L,
)
)
),
displayThreadSummaries = true,
@@ -860,6 +977,149 @@ internal fun TimelineItemEventRowWithThreadSummaryPreview() = ElementPreview {
}
}
@PreviewWithExtraLargeHeight
@Composable
internal fun TimelineItemEventRowRtlContentPreview() = ElementPreview {
Column {
Text(
modifier = Modifier
.fillMaxWidth()
.background(Color.LightGray)
.padding(8.dp),
text = "LTR layout direction",
textAlign = TextAlign.Center,
style = ElementTheme.typography.fontHeadingMdBold,
)
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
sequenceOf(false, true).forEach { isMine ->
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = isMine,
content = aTimelineItemTextContent(
body = "ظَة وَدَاع يَسْتَغْرِب فِيهَ"
),
timelineItemReactions = TimelineItemReactions(persistentListOf()),
groupPosition = TimelineItemGroupPosition.First,
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = isMine,
content = aTimelineItemTextContent(
body = "ظَة وَدَاع يَسْتَغْرِب فِيهَ",
isEdited = true,
),
timelineItemReactions = TimelineItemReactions(persistentListOf()),
groupPosition = TimelineItemGroupPosition.Middle,
messageShield = MessageShield.UnknownDevice(isCritical = true),
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = isMine,
content = aTimelineItemTextContent(
body = "ظَة وَدَاع \nيَسْتَغْرِب فِيهَ"
),
timelineItemReactions = TimelineItemReactions(persistentListOf()),
groupPosition = TimelineItemGroupPosition.Middle,
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = isMine,
content = aTimelineItemTextContent(
body = "ظَة وَدَاع يَسْتَغْرِب فِيهَا اَلشَّاعِر أَنْ لَا يَبْكِي مِنْ أَلَم اَلْفِرَاق،" +
" وَيَصِف حَالَة اَلْمُودِعِينَ"
),
timelineItemReactions = TimelineItemReactions(persistentListOf()),
groupPosition = TimelineItemGroupPosition.Last,
)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
modifier = Modifier
.fillMaxWidth()
.background(Color.LightGray)
.padding(8.dp),
text = "RTL layout direction",
textAlign = TextAlign.Center,
style = ElementTheme.typography.fontHeadingMdBold,
)
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
sequenceOf(false, true).forEach { isMine ->
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = isMine,
content = aTimelineItemTextContent(
body = "ظَة وَدَاع يَسْتَغْرِب فِيهَا اَلشَّاعِر أَنْ لَا يَبْكِي مِنْ أَلَم اَلْفِرَاق،" +
" وَيَصِف حَالَة اَلْمُودِعِينَ",
),
timelineItemReactions = TimelineItemReactions(persistentListOf()),
groupPosition = TimelineItemGroupPosition.First,
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = isMine,
content = aTimelineItemTextContent(
body = "Testing\nLTR Line\nBreaks.",
),
timelineItemReactions = TimelineItemReactions(persistentListOf()),
groupPosition = TimelineItemGroupPosition.Middle,
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = isMine,
content = aTimelineItemTextContent(
body = "Testing a very long LTR text in an RTL layout."
),
timelineItemReactions = TimelineItemReactions(persistentListOf()),
groupPosition = TimelineItemGroupPosition.Middle,
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = isMine,
content = aTimelineItemTextContent(
body = "Testing LTR in RTL layout.",
),
timelineItemReactions = TimelineItemReactions(persistentListOf()),
groupPosition = TimelineItemGroupPosition.Last,
messageShield = MessageShield.UnknownDevice(isCritical = true),
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = isMine,
content = aTimelineItemTextContent(
body = "Testing LTR in RTL layout.",
isEdited = true,
),
timelineItemReactions = TimelineItemReactions(persistentListOf()),
groupPosition = TimelineItemGroupPosition.Last,
messageShield = MessageShield.UnknownDevice(isCritical = true),
)
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun ThreadSummaryViewPreview() {
@@ -878,9 +1138,10 @@ internal fun ThreadSummaryViewPreview() {
),
senderId = UserId("@user:id"),
senderProfile = ProfileDetails.Ready(
displayName = "Alice",
displayName = USER_NAME_ALICE,
avatarUrl = null,
displayNameAmbiguous = true,
displayedStatus = null,
),
timestamp = 0L,
)
@@ -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,
),
)
}
}
}
@@ -16,6 +16,8 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemGrou
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.designsystem.preview.USER_NAME_BOB
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UtdCause
@@ -25,7 +27,7 @@ internal fun TimelineItemEventRowUtdPreview() = ElementPreview {
Column {
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Alice",
senderDisplayName = USER_NAME_ALICE,
isMine = false,
content = TimelineItemEncryptedContent(
data = UnableToDecryptContent.Data.MegolmV1AesSha2(
@@ -39,7 +41,7 @@ internal fun TimelineItemEventRowUtdPreview() = ElementPreview {
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Bob",
senderDisplayName = USER_NAME_BOB,
isMine = false,
content = TimelineItemEncryptedContent(
data = UnableToDecryptContent.Data.MegolmV1AesSha2(
@@ -54,7 +56,7 @@ internal fun TimelineItemEventRowUtdPreview() = ElementPreview {
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Bob",
senderDisplayName = USER_NAME_BOB,
isMine = false,
content = TimelineItemEncryptedContent(
data = UnableToDecryptContent.Data.MegolmV1AesSha2(
@@ -37,7 +37,6 @@ internal fun TimelineItemEventRowWithRRPreview(
timelineItemReactions = aTimelineItemReactions(count = 0),
readReceiptState = TimelineItemReadReceipts(state.receipts),
),
renderReadReceipts = true,
isLastOutgoingMessage = false,
)
// A message from current user
@@ -49,7 +48,6 @@ internal fun TimelineItemEventRowWithRRPreview(
timelineItemReactions = aTimelineItemReactions(count = 0),
readReceiptState = TimelineItemReadReceipts(state.receipts),
),
renderReadReceipts = true,
isLastOutgoingMessage = false,
)
// Another message from current user
@@ -61,7 +59,6 @@ internal fun TimelineItemEventRowWithRRPreview(
timelineItemReactions = aTimelineItemReactions(count = 0),
readReceiptState = TimelineItemReadReceipts(state.receipts),
),
renderReadReceipts = true,
isLastOutgoingMessage = true,
)
}
@@ -19,14 +19,15 @@ import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.TimelineEvent
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.aGroupedEvents
import io.element.android.features.messages.impl.timeline.aRedactedMessagesGroupedEvents
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.groups.isRedactedMessagesGroup
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -34,7 +35,6 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.wysiwyg.link.Link
@Composable
@@ -43,7 +43,6 @@ fun TimelineItemGroupedEventsRow(
timelineMode: Timeline.Mode,
timelineRoomInfo: TimelineRoomInfo,
timelineProtectionState: TimelineProtectionState,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
focusedEventId: EventId?,
displayThreadSummaries: Boolean,
@@ -62,14 +61,15 @@ fun TimelineItemGroupedEventsRow(
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit =
{ event, contentModifier, onContentLayoutChange ->
TimelineItemEventContentView(
eventId = event.eventId,
content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
timelineProtectionState = timelineProtectionState,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
eventSink = eventSink,
modifier = contentModifier,
onContentClick = null,
onGalleryItemClick = {},
onLongClick = null,
onContentLayoutChange = onContentLayoutChange
)
@@ -89,7 +89,6 @@ fun TimelineItemGroupedEventsRow(
timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
focusedEventId = focusedEventId,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
displayThreadSummaries = displayThreadSummaries,
onClick = onClick,
@@ -117,7 +116,6 @@ private fun TimelineItemGroupedEventsRowContent(
timelineRoomInfo: TimelineRoomInfo,
timelineProtectionState: TimelineProtectionState,
focusedEventId: EventId?,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
displayThreadSummaries: Boolean,
onClick: (TimelineItem.Event) -> Unit,
@@ -135,45 +133,45 @@ private fun TimelineItemGroupedEventsRowContent(
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit =
{ event, contentModifier, onContentLayoutChange ->
TimelineItemEventContentView(
eventId = event.eventId,
content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
timelineProtectionState = timelineProtectionState,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
eventSink = eventSink,
modifier = contentModifier,
onContentClick = null,
onGalleryItemClick = {},
onLongClick = null,
onContentLayoutChange = onContentLayoutChange
)
},
) {
Column(modifier = modifier.animateContentSize()) {
val count = timelineItem.events.size
// A group made entirely of redacted events is a collapsed run of deleted messages
// (element-web style); anything else is the regular run of room state changes. For the
// redacted case we show only the count: the SDK does not expose who performed the redaction,
// and showing the original authors would be misleading.
val headerText = if (timelineItem.isRedactedMessagesGroup()) {
pluralStringResource(R.plurals.screen_room_timeline_redacted_messages, count, count)
} else {
pluralStringResource(R.plurals.screen_room_timeline_state_changes, count, count)
}
GroupHeaderView(
text = pluralStringResource(
id = R.plurals.screen_room_timeline_state_changes,
count = timelineItem.events.size,
timelineItem.events.size
),
text = headerText,
isExpanded = isExpanded,
isHighlighted = !isExpanded && timelineItem.events.any { it.isEvent(focusedEventId) },
onClick = onExpandGroupClick,
)
if (isExpanded) {
Column {
timelineItem.events.let {
if (isTalkbackActive()) {
it.reversed()
} else {
it
}
}.forEach { subGroupEvent ->
timelineItem.events.forEach { subGroupEvent ->
TimelineItemRow(
timelineMode = timelineMode,
timelineItem = subGroupEvent,
timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
focusedEventId = focusedEventId,
displayThreadSummaries = displayThreadSummaries,
@@ -181,27 +179,27 @@ private fun TimelineItemGroupedEventsRowContent(
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onContentClick = onClick,
onGalleryItemClick = { _, _ -> },
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
onSwipeToReply = {},
onJoinCallClick = {},
onSwipeToReply = {},
eventSink = eventSink,
eventContentView = eventContentView,
)
}
}
} else if (renderReadReceipts) {
} else if (timelineItem.aggregatedReadReceipts.isNotEmpty()) {
TimelineItemReadReceiptView(
state = ReadReceiptViewState(
sendState = null,
isLastOutgoingMessage = false,
receipts = timelineItem.aggregatedReadReceipts,
),
renderReadReceipts = true,
onReadReceiptsClick = onExpandGroupClick
)
}
@@ -220,7 +218,6 @@ internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPrevi
timelineRoomInfo = aTimelineRoomInfo(),
timelineProtectionState = aTimelineProtectionState(),
focusedEventId = events.events.first().eventId,
renderReadReceipts = true,
isLastOutgoingMessage = false,
displayThreadSummaries = false,
onClick = {},
@@ -248,7 +245,34 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi
timelineRoomInfo = aTimelineRoomInfo(),
timelineProtectionState = aTimelineProtectionState(),
focusedEventId = null,
renderReadReceipts = true,
isLastOutgoingMessage = false,
displayThreadSummaries = false,
onClick = {},
onLongClick = {},
onLinkLongClick = {},
inReplyToClick = {},
onUserDataClick = {},
onLinkClick = {},
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
eventSink = {},
)
}
@PreviewsDayNight
@Composable
internal fun TimelineItemRedactedMessagesGroupPreview() = ElementPreview {
// A collapsed run of deleted messages, shown as a single "N removed messages" header.
TimelineItemGroupedEventsRowContent(
isExpanded = false,
onExpandGroupClick = {},
timelineItem = aRedactedMessagesGroupedEvents(count = 11),
timelineMode = Timeline.Mode.Live,
timelineRoomInfo = aTimelineRoomInfo(),
timelineProtectionState = aTimelineProtectionState(),
focusedEventId = null,
isLastOutgoingMessage = false,
displayThreadSummaries = false,
onClick = {},
@@ -31,12 +31,12 @@ import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
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.TimelineItemRtcNotificationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.libraries.designsystem.colors.gradientSubtleColors
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
@@ -47,7 +47,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.wysiwyg.link.Link
import kotlin.time.DurationUnit
@@ -56,7 +56,6 @@ internal fun TimelineItemRow(
timelineItem: TimelineItem,
timelineMode: Timeline.Mode,
timelineRoomInfo: TimelineRoomInfo,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
timelineProtectionState: TimelineProtectionState,
focusedEventId: EventId?,
@@ -65,29 +64,31 @@ 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,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
onSwipeToReply: (TimelineItem.Event) -> Unit,
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
onSwipeToReply: (TimelineItem.Event) -> Unit,
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
modifier: Modifier = Modifier,
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit =
{ event, contentModifier, onContentLayoutChange ->
TimelineItemEventContentView(
eventId = event.eventId,
content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
timelineProtectionState = timelineProtectionState,
onContentClick = { onContentClick(event) },
onGalleryItemClick = { index -> onGalleryItemClick(event, index) },
onLongClick = { onLongClick(event) },
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
eventSink = eventSink,
modifier = contentModifier,
onContentLayoutChange = onContentLayoutChange
onContentLayoutChange = onContentLayoutChange,
)
},
) {
@@ -115,22 +116,36 @@ internal fun TimelineItemRow(
is TimelineItemStateContent, is TimelineItemLegacyCallInviteContent -> {
TimelineItemStateEventRow(
event = timelineItem,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
onClick = { onContentClick(timelineItem) },
onReadReceiptsClick = onReadReceiptClick,
onLongClick = { onLongClick(timelineItem) },
timelineProtectionState = timelineProtectionState,
eventSink = eventSink,
)
}
is TimelineItemRtcNotificationContent -> {
TimelineItemCallNotifyView(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp),
event = timelineItem,
roomCallState = timelineRoomInfo.roomCallState,
onLongClick = onLongClick,
onJoinCallClick = onJoinCallClick,
)
when (timelineItem.content.state) {
is RtcNotificationState.Active -> ActiveCallTimelineItemView(
timelineRoomInfo = timelineRoomInfo,
event = timelineItem,
state = timelineItem.content.state,
isLastOutgoingMessage = isLastOutgoingMessage,
onLongClick = onLongClick,
onReadReceiptsClick = onReadReceiptClick,
onJoinCallClick = onJoinCallClick,
)
is RtcNotificationState.Started, is RtcNotificationState.Declined ->
TimelineItemCallNotifyView(
timelineRoomInfo = timelineRoomInfo,
event = timelineItem,
content = timelineItem.content,
state = timelineItem.content.state,
isLastOutgoingMessage = isLastOutgoingMessage,
onLongClick = onLongClick,
onReadReceiptsClick = onReadReceiptClick,
)
}
}
else -> {
val a11yVoiceMessage = stringResource(CommonStrings.a11y_voice_message)
@@ -166,7 +181,6 @@ internal fun TimelineItemRow(
event = timelineItem,
timelineMode = timelineMode,
timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts,
timelineProtectionState = timelineProtectionState,
isLastOutgoingMessage = isLastOutgoingMessage,
displayThreadSummaries = displayThreadSummaries,
@@ -181,6 +195,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)
@@ -195,7 +210,6 @@ internal fun TimelineItemRow(
timelineMode = timelineMode,
timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
focusedEventId = focusedEventId,
displayThreadSummaries = displayThreadSummaries,
@@ -31,6 +31,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.TimelineItemReadReceipts
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.features.messages.impl.timeline.util.defaultTimelineContentPadding
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -39,11 +41,11 @@ import kotlinx.collections.immutable.persistentListOf
@Composable
fun TimelineItemStateEventRow(
event: TimelineItem.Event,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
onReadReceiptsClick: (event: TimelineItem.Event) -> Unit,
timelineProtectionState: TimelineProtectionState,
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
modifier: Modifier = Modifier
) {
@@ -68,13 +70,14 @@ fun TimelineItemStateEventRow(
.widthIn(max = 320.dp)
) {
TimelineItemEventContentView(
eventId = event.eventId,
content = event.content,
onLinkClick = {},
onLinkLongClick = {},
hideMediaContent = false,
onShowContentClick = {},
timelineProtectionState = timelineProtectionState,
eventSink = eventSink,
onContentClick = null,
onGalleryItemClick = {},
onLongClick = null,
modifier = Modifier.defaultTimelineContentPadding()
)
@@ -86,7 +89,6 @@ fun TimelineItemStateEventRow(
isLastOutgoingMessage = isLastOutgoingMessage,
receipts = event.readReceiptState.receipts,
),
renderReadReceipts = renderReadReceipts,
onReadReceiptsClick = { onReadReceiptsClick(event) },
)
}
@@ -104,11 +106,11 @@ internal fun TimelineItemStateEventRowPreview() = ElementPreview {
receipts = persistentListOf(aReadReceiptData(0)),
)
),
renderReadReceipts = true,
isLastOutgoingMessage = false,
onClick = {},
onLongClick = {},
onReadReceiptsClick = {},
timelineProtectionState = aTimelineProtectionState(),
eventSink = {}
)
}
@@ -22,7 +22,12 @@ enum class TimestampPosition {
/**
* Timestamp should always be rendered below the timeline event content (eg. poll).
*/
Below;
Below,
/**
* Timestamp should be hidden.
*/
Hidden;
companion object {
/**
@@ -10,17 +10,16 @@ package io.element.android.features.messages.impl.timeline.components.customreac
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import io.element.android.emojibasebindings.Emoji
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.EmojiPicker
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.EmojiPickerPresenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.hide
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
@OptIn(ExperimentalMaterial3Api::class)
@@ -28,43 +27,43 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransa
fun CustomReactionBottomSheet(
state: CustomReactionState,
onSelectEmoji: (EventOrTransactionId, Emoji) -> Unit,
emojiPickerRenderer: EmojiPickerRenderer,
modifier: Modifier = Modifier,
) {
val sheetState = rememberModalBottomSheetState()
val sheetState = rememberBottomSheetState(initialValue = SheetValue.Hidden)
val coroutineScope = rememberCoroutineScope()
val target = state.target as? CustomReactionState.Target.Success
fun onDismiss() {
state.eventSink(CustomReactionEvent.DismissCustomReactionSheet)
}
when (state.target) {
CustomReactionState.Target.None,
is CustomReactionState.Target.Loading -> Unit
is CustomReactionState.Target.Success -> {
fun onEmojiSelectedDismiss(emoji: Emoji) {
sheetState.hide(coroutineScope) {
state.eventSink(CustomReactionEvent.DismissCustomReactionSheet)
onSelectEmoji(state.target.event.eventOrTransactionId, emoji)
}
}
fun onEmojiSelectedDismiss(emoji: Emoji) {
if (target?.event == null) return
sheetState.hide(coroutineScope) {
state.eventSink(CustomReactionEvent.DismissCustomReactionSheet)
onSelectEmoji(target.event.eventOrTransactionId, emoji)
}
}
fun onDismiss() {
state.eventSink(CustomReactionEvent.DismissCustomReactionSheet)
}
if (target?.emojibaseStore != null && target.event.eventId != null) {
ModalBottomSheet(
onDismissRequest = ::onDismiss,
sheetState = sheetState,
modifier = modifier
) {
val presenter = remember {
EmojiPickerPresenter(
emojibaseStore = target.emojibaseStore,
recentEmojis = state.recentEmojis,
coroutineDispatchers = CoroutineDispatchers.Default,
ModalBottomSheet(
onDismissRequest = ::onDismiss,
sheetState = sheetState,
modifier = modifier,
scrollable = false,
) {
emojiPickerRenderer.Render(
state = state.target.emojiPickerState,
onSelectEmoji = ::onEmojiSelectedDismiss,
selectedEmojis = state.selectedEmoji,
modifier = Modifier.fillMaxSize(),
contentDescription = { emoji, isSelected ->
a11yReactionAction(emoji = emoji.unicode, userAlreadyReacted = isSelected)
},
)
}
EmojiPicker(
onSelectEmoji = ::onEmojiSelectedDismiss,
state = presenter.present(),
selectedEmojis = state.selectedEmoji,
modifier = Modifier.fillMaxSize(),
)
}
}
}
@@ -9,59 +9,53 @@
package io.element.android.features.messages.impl.timeline.components.customreaction
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.recentemojis.api.EmojibaseProvider
import io.element.android.libraries.recentemojis.api.GetRecentEmojis
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import io.element.android.libraries.emoji.api.picker.EmojiPickerPresenter
import io.element.android.libraries.emoji.api.recentemojis.GetRecentEmojis
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.launch
@Inject
class CustomReactionPresenter(
private val emojibaseProvider: EmojibaseProvider,
private val getRecentEmojis: GetRecentEmojis,
emojiPickerPresenterFactory: EmojiPickerPresenter.Factory,
getRecentEmojis: GetRecentEmojis,
) : Presenter<CustomReactionState> {
private val emojiPickerPresenter = emojiPickerPresenterFactory.create(getRecentEmojis)
@Composable
override fun present(): CustomReactionState {
val localCoroutineScope = rememberCoroutineScope()
var recentEmojis by remember { mutableStateOf<ImmutableList<String>>(persistentListOf()) }
val target: MutableState<CustomReactionState.Target> = remember {
mutableStateOf(CustomReactionState.Target.None)
}
fun handleShowCustomReactionSheet(event: TimelineItem.Event) {
target.value = CustomReactionState.Target.Loading(event)
localCoroutineScope.launch {
recentEmojis = getRecentEmojis().getOrNull() ?: persistentListOf()
target.value = CustomReactionState.Target.Success(
event = event,
emojibaseStore = emojibaseProvider.emojibaseStore
)
}
}
fun handleDismissCustomReactionSheet() {
target.value = CustomReactionState.Target.None
}
var internalTarget by remember { mutableStateOf<InternalTarget>(InternalTarget.None) }
val emojiPickerState = emojiPickerPresenter.present()
fun handleEvent(event: CustomReactionEvent) {
when (event) {
is CustomReactionEvent.ShowCustomReactionSheet -> handleShowCustomReactionSheet(event.event)
is CustomReactionEvent.DismissCustomReactionSheet -> handleDismissCustomReactionSheet()
is CustomReactionEvent.ShowCustomReactionSheet -> {
internalTarget = InternalTarget.Showing(event.event)
}
is CustomReactionEvent.DismissCustomReactionSheet -> {
internalTarget = InternalTarget.None
}
}
}
val event = (target.value as? CustomReactionState.Target.Success)?.event
val selectedEmoji = event
val computedTarget = when (val target = internalTarget) {
InternalTarget.None -> CustomReactionState.Target.None
is InternalTarget.Showing -> if (emojiPickerState.isReady) {
CustomReactionState.Target.Success(
event = target.event,
emojiPickerState = emojiPickerState,
)
} else {
CustomReactionState.Target.Loading(target.event)
}
}
val eventForSelection = (computedTarget as? CustomReactionState.Target.Success)?.event
val selectedEmoji = eventForSelection
?.reactionsState
?.reactions
?.mapNotNull { if (it.isHighlighted) it.key else null }
@@ -69,10 +63,14 @@ class CustomReactionPresenter(
.toImmutableSet()
return CustomReactionState(
target = target.value,
target = computedTarget,
selectedEmoji = selectedEmoji,
recentEmojis = recentEmojis,
eventSink = ::handleEvent,
)
}
}
private sealed interface InternalTarget {
data object None : InternalTarget
data class Showing(val event: TimelineItem.Event) : InternalTarget
}
@@ -8,15 +8,13 @@
package io.element.android.features.messages.impl.timeline.components.customreaction
import io.element.android.emojibasebindings.EmojibaseStore
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import kotlinx.collections.immutable.ImmutableList
import io.element.android.libraries.emoji.api.picker.EmojiPickerState
import kotlinx.collections.immutable.ImmutableSet
data class CustomReactionState(
val target: Target,
val selectedEmoji: ImmutableSet<String>,
val recentEmojis: ImmutableList<String>,
val eventSink: (CustomReactionEvent) -> Unit,
) {
sealed interface Target {
@@ -24,7 +22,7 @@ data class CustomReactionState(
data class Loading(val event: TimelineItem.Event) : Target
data class Success(
val event: TimelineItem.Event,
val emojibaseStore: EmojibaseStore,
val emojiPickerState: EmojiPickerState,
) : Target
}
}
@@ -1,100 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components.customreaction
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.compound.theme.ElementTheme
import io.element.android.emojibasebindings.Emoji
import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Text
import kotlinx.collections.immutable.persistentListOf
@Composable
fun EmojiItem(
item: Emoji,
isSelected: Boolean,
onSelectEmoji: (Emoji) -> Unit,
modifier: Modifier = Modifier,
emojiSize: TextUnit = 20.sp,
) {
val backgroundColor = if (isSelected) {
ElementTheme.colors.bgActionPrimaryRest
} else {
Color.Transparent
}
val description = a11yReactionAction(
emoji = item.unicode,
userAlreadyReacted = isSelected,
)
Box(
modifier = modifier
.sizeIn(minWidth = 40.dp, minHeight = 40.dp)
.background(backgroundColor, CircleShape)
.clickable(
enabled = true,
onClick = { onSelectEmoji(item) },
indication = ripple(bounded = false, radius = emojiSize.toDp() / 2 + 10.dp),
interactionSource = remember { MutableInteractionSource() }
)
.clearAndSetSemantics {
contentDescription = description
},
contentAlignment = Alignment.Center
) {
Text(
text = item.unicode,
style = LocalTextStyle.current.copy(fontSize = emojiSize),
)
}
}
@PreviewsDayNight
@Composable
internal fun EmojiItemPreview() = ElementPreview {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
for (isSelected in listOf(true, false)) {
EmojiItem(
item = Emoji(
hexcode = "",
label = "",
tags = null,
shortcodes = persistentListOf(),
unicode = "👍",
skins = null
),
isSelected = isSelected,
onSelectEmoji = {},
)
}
}
}
@@ -1,48 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components.customreaction
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.EmojiEvents
import androidx.compose.material.icons.outlined.EmojiFlags
import androidx.compose.material.icons.outlined.EmojiFoodBeverage
import androidx.compose.material.icons.outlined.EmojiNature
import androidx.compose.material.icons.outlined.EmojiObjects
import androidx.compose.material.icons.outlined.EmojiPeople
import androidx.compose.material.icons.outlined.EmojiSymbols
import androidx.compose.material.icons.outlined.EmojiTransportation
import androidx.compose.ui.graphics.vector.ImageVector
import io.element.android.emojibasebindings.EmojibaseCategory
import io.element.android.features.messages.impl.R
@get:StringRes
val EmojibaseCategory.title: Int
get() = when (this) {
EmojibaseCategory.People -> R.string.emoji_picker_category_people
EmojibaseCategory.Nature -> R.string.emoji_picker_category_nature
EmojibaseCategory.Foods -> R.string.emoji_picker_category_foods
EmojibaseCategory.Activity -> R.string.emoji_picker_category_activity
EmojibaseCategory.Places -> R.string.emoji_picker_category_places
EmojibaseCategory.Objects -> R.string.emoji_picker_category_objects
EmojibaseCategory.Symbols -> R.string.emoji_picker_category_symbols
EmojibaseCategory.Flags -> R.string.emoji_picker_category_flags
}
val EmojibaseCategory.icon: ImageVector
get() = when (this) {
EmojibaseCategory.People -> Icons.Outlined.EmojiPeople
EmojibaseCategory.Nature -> Icons.Outlined.EmojiNature
EmojibaseCategory.Foods -> Icons.Outlined.EmojiFoodBeverage
EmojibaseCategory.Activity -> Icons.Outlined.EmojiEvents
EmojibaseCategory.Places -> Icons.Outlined.EmojiTransportation
EmojibaseCategory.Objects -> Icons.Outlined.EmojiObjects
EmojibaseCategory.Symbols -> Icons.Outlined.EmojiSymbols
EmojibaseCategory.Flags -> Icons.Outlined.EmojiFlags
}
@@ -1,149 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components.customreaction.picker
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.emojibasebindings.Emoji
import io.element.android.features.messages.impl.timeline.components.customreaction.EmojiItem
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toSp
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EmojiPicker(
onSelectEmoji: (Emoji) -> Unit,
state: EmojiPickerState,
selectedEmojis: ImmutableSet<String>,
modifier: Modifier = Modifier,
) {
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState(pageCount = { state.categories.size })
Column(modifier) {
SearchBar(
modifier = Modifier.padding(bottom = 10.dp),
queryState = state.searchQuery,
resultState = state.searchResults,
active = state.isSearchActive,
onActiveChange = { state.eventSink(EmojiPickerEvent.ToggleSearchActive(it)) },
windowInsets = WindowInsets(0, 0, 0, 0),
placeHolderTitle = stringResource(CommonStrings.emoji_picker_search_placeholder),
) { emojis ->
EmojiResults(
emojis = emojis,
isEmojiSelected = { selectedEmojis.contains(it.unicode) },
onSelectEmoji = onSelectEmoji,
)
}
if (!state.isSearchActive) {
SecondaryTabRow(
selectedTabIndex = pagerState.currentPage,
) {
state.categories.forEachIndexed { index, category ->
Tab(
icon = {
when (category.icon) {
is IconSource.Resource -> Icon(
resourceId = category.icon.id,
contentDescription = stringResource(id = category.titleId)
)
is IconSource.Vector -> Icon(
imageVector = category.icon.vector,
contentDescription = stringResource(id = category.titleId)
)
}
},
selected = pagerState.currentPage == index,
onClick = {
coroutineScope.launch { pagerState.animateScrollToPage(index) }
}
)
}
}
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxWidth(),
) { index ->
val emojis = state.categories[index].emojis
EmojiResults(
emojis = emojis,
isEmojiSelected = { selectedEmojis.contains(it.unicode) },
onSelectEmoji = onSelectEmoji,
)
}
}
}
}
@Composable
private fun EmojiResults(
emojis: ImmutableList<Emoji>,
isEmojiSelected: (Emoji) -> Boolean,
onSelectEmoji: (Emoji) -> Unit,
) {
LazyVerticalGrid(
modifier = Modifier.fillMaxSize(),
columns = GridCells.Adaptive(minSize = 48.dp),
contentPadding = PaddingValues(vertical = 10.dp, horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
items(emojis, key = { it.unicode }) { item ->
EmojiItem(
modifier = Modifier.aspectRatio(1f),
item = item,
isSelected = isEmojiSelected(item),
onSelectEmoji = onSelectEmoji,
emojiSize = 32.dp.toSp(),
)
}
}
}
@PreviewsDayNight
@Composable
internal fun EmojiPickerPreview(@PreviewParameter(EmojiPickerStateProvider::class) state: EmojiPickerState) = ElementPreview {
EmojiPicker(
onSelectEmoji = {},
state = state,
selectedEmojis = persistentSetOf("😀", "😄", "😃"),
modifier = Modifier.fillMaxWidth(),
)
}
@@ -1,13 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components.customreaction.picker
sealed interface EmojiPickerEvent {
data class ToggleSearchActive(val isActive: Boolean) : EmojiPickerEvent
}
@@ -1,114 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components.customreaction.picker
import androidx.compose.foundation.text.input.rememberTextFieldState
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.ui.platform.LocalInspectionMode
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.emojibasebindings.Emoji
import io.element.android.emojibasebindings.EmojibaseStore
import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.components.customreaction.icon
import io.element.android.features.messages.impl.timeline.components.customreaction.title
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.milliseconds
class EmojiPickerPresenter(
private val emojibaseStore: EmojibaseStore,
private val recentEmojis: ImmutableList<String>,
private val coroutineDispatchers: CoroutineDispatchers,
) : Presenter<EmojiPickerState> {
@Composable
override fun present(): EmojiPickerState {
val queryState = rememberTextFieldState()
var isSearchActive by remember { mutableStateOf(false) }
var emojiResults by remember { mutableStateOf<SearchBarResultState<ImmutableList<Emoji>>>(SearchBarResultState.Initial()) }
val recentEmojiIcon = CompoundIcons.History()
val categories = remember {
val providedCategories = emojibaseStore.categories.map { (category, emojis) ->
EmojiCategory(
titleId = category.title,
icon = IconSource.Vector(category.icon),
emojis = emojis
)
}
if (recentEmojis.isNotEmpty()) {
val recentEmojis = recentEmojis.mapNotNull { recentEmoji ->
emojibaseStore.allEmojis.find { it.unicode == recentEmoji }
}.toImmutableList()
val recentCategory =
EmojiCategory(
titleId = R.string.emoji_picker_category_recent,
icon = IconSource.Vector(recentEmojiIcon),
emojis = recentEmojis
)
(listOf(recentCategory) + providedCategories).toImmutableList()
} else {
providedCategories.toImmutableList()
}
}
val searchQuery = queryState.text.toString()
LaunchedEffect(searchQuery) {
emojiResults = if (searchQuery.isEmpty()) {
SearchBarResultState.Initial()
} else {
// Add a small delay to avoid doing too many computations when the user is typing quickly
delay(100.milliseconds)
val lowercaseQuery = searchQuery.lowercase()
val results = withContext(coroutineDispatchers.computation) {
emojibaseStore.allEmojis
.asSequence()
.filter { emoji ->
emoji.tags.orEmpty().any { it.contains(lowercaseQuery) } ||
emoji.shortcodes.any { it.contains(lowercaseQuery) }
}
.take(60)
.toImmutableList()
}
SearchBarResultState.Results(results)
}
}
val isInPreview = LocalInspectionMode.current
fun handleEvent(event: EmojiPickerEvent) {
when (event) {
// For some reason, in preview mode the SearchBar emits this event with an `isActive = true` value automatically
is EmojiPickerEvent.ToggleSearchActive -> if (!isInPreview) {
isSearchActive = event.isActive
}
}
}
return EmojiPickerState(
categories = categories,
allEmojis = emojibaseStore.allEmojis,
searchQuery = queryState,
isSearchActive = isSearchActive,
searchResults = emojiResults,
eventSink = ::handleEvent,
)
}
}
@@ -1,37 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components.customreaction.picker
import androidx.annotation.StringRes
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.Immutable
import io.element.android.emojibasebindings.Emoji
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import kotlinx.collections.immutable.ImmutableList
// Emoji is unstable (because from an external library?), so we annotate with @Immutable
@Immutable
data class EmojiPickerState(
val categories: ImmutableList<EmojiCategory>,
val allEmojis: ImmutableList<Emoji>,
val searchQuery: TextFieldState,
val isSearchActive: Boolean,
val searchResults: SearchBarResultState<ImmutableList<Emoji>>,
val eventSink: (EmojiPickerEvent) -> Unit,
)
/**
* Represents a category of emojis with a title id, icon, and the list of associated emojis.
*/
data class EmojiCategory(
@StringRes val titleId: Int,
val icon: IconSource,
val emojis: ImmutableList<Emoji>,
)
@@ -1,84 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components.customreaction.picker
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.emojibasebindings.Emoji
import io.element.android.emojibasebindings.EmojibaseCategory
import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.components.customreaction.icon
import io.element.android.features.messages.impl.timeline.components.customreaction.title
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
class EmojiPickerStateProvider : PreviewParameterProvider<EmojiPickerState> {
override val values: Sequence<EmojiPickerState>
get() = sequenceOf(
anEmojiPickerState(),
anEmojiPickerState(isSearchActive = true),
anEmojiPickerState(isSearchActive = true, searchQuery = "smile"),
anEmojiPickerState(
isSearchActive = true,
searchQuery = "smile",
searchResults = SearchBarResultState.Results(emojiList())
),
)
}
private fun recentEmojisCategory() = EmojiCategory(
titleId = R.string.emoji_picker_category_recent,
icon = IconSource.Resource(CompoundDrawables.ic_compound_history),
emojis = emojiList(),
)
private fun emojiList(): ImmutableList<Emoji> = persistentListOf(
Emoji(
"0x00",
"grinning face",
persistentListOf("grinning"),
persistentListOf("smile, grin"),
"😀",
null
),
Emoji(
"0x01",
"crying face",
persistentListOf("crying"),
persistentListOf("smile, crying"),
"\uD83E\uDD72",
null
)
)
internal fun anEmojiPickerState(
categories: ImmutableList<EmojiCategory> = (listOf(recentEmojisCategory()) + EmojibaseCategory.entries.map {
EmojiCategory(
titleId = it.title,
icon = IconSource.Vector(it.icon),
emojis = emojiList(),
)
}).toImmutableList(),
allEmojis: ImmutableList<Emoji> = categories.flatMap { it.emojis }.toImmutableList(),
searchQuery: String = "",
isSearchActive: Boolean = false,
searchResults: SearchBarResultState<ImmutableList<Emoji>> = SearchBarResultState.Initial(),
eventSink: (EmojiPickerEvent) -> Unit = {},
) = EmojiPickerState(
categories = categories,
allEmojis = allEmojis,
searchQuery = TextFieldState(initialText = searchQuery),
isSearchActive = isSearchActive,
searchResults = searchResults,
eventSink = eventSink,
)
@@ -13,7 +13,6 @@ 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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
@@ -25,8 +24,10 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
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.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
/**
* package-private, you should only use TimelineItemFileView and TimelineItemAudioView.
@@ -38,9 +39,9 @@ fun TimelineItemAttachmentView(
iconContentDescription: String?,
filename: String,
fileExtensionAndSize: String,
caption: String?,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
contentValidationValue: ContentValidationValue = ContentValidationValue.Valid,
) {
Column(
modifier = modifier,
@@ -50,16 +51,9 @@ fun TimelineItemAttachmentView(
iconContentDescription = iconContentDescription,
filename = filename,
fileExtensionAndSize = fileExtensionAndSize,
hasCaption = caption != null,
isLoading = !contentValidationValue.isValidated(),
onContentLayoutChange = onContentLayoutChange,
)
if (caption != null) {
TimelineItemAttachmentCaptionView(
modifier = Modifier.padding(top = 4.dp),
caption = caption,
onContentLayoutChange = onContentLayoutChange,
)
}
}
}
@@ -69,7 +63,7 @@ private fun TimelineItemAttachmentHeaderView(
iconContentDescription: String?,
filename: String,
fileExtensionAndSize: String,
hasCaption: Boolean,
isLoading: Boolean,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -86,12 +80,19 @@ private fun TimelineItemAttachmentHeaderView(
.background(ElementTheme.colors.bgCanvasDefault, RoundedCornerShape(4.dp)),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = iconContentDescription,
tint = ElementTheme.colors.iconPrimary,
modifier = Modifier.size(24.dp),
)
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = ElementTheme.colors.iconPrimary,
)
} else {
Icon(
imageVector = icon,
contentDescription = iconContentDescription,
tint = ElementTheme.colors.iconPrimary,
modifier = Modifier.size(24.dp),
)
}
}
Column {
Text(
@@ -107,32 +108,11 @@ private fun TimelineItemAttachmentHeaderView(
style = ElementTheme.typography.fontBodySmRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
onTextLayout = if (hasCaption) {
{}
} else {
ContentAvoidingLayout.measureLastTextLine(
onContentLayoutChange = onContentLayoutChange,
extraWidth = iconSize + spacing
)
},
onTextLayout = ContentAvoidingLayout.measureLastTextLine(
onContentLayoutChange = onContentLayoutChange,
extraWidth = iconSize + spacing
),
)
}
}
}
@Composable
private fun TimelineItemAttachmentCaptionView(
caption: String,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {
Text(
modifier = modifier,
text = caption,
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontBodyLgRegular,
onTextLayout = ContentAvoidingLayout.measureLastTextLine(
onContentLayoutChange = onContentLayoutChange,
)
)
}
@@ -0,0 +1,75 @@
/*
* 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 = "spreadsheet.xlsx",
mimeType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
fileSize = null,
formattedFileSize = "450 KB",
fileExtension = "XLSX",
),
),
),
aTimelineItemAttachmentsContent(
body = "Files",
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",
),
anAttachmentItem(
filename = "photo.jpg",
mimeType = "image/jpeg",
thumbnailSource = MediaSource(url = "thumb", json = ""),
fileSize = null,
formattedFileSize = "1.2 MB",
fileExtension = "JPG",
),
anAttachmentItem(
filename = "video.mp4",
mimeType = "video/mp4",
thumbnailSource = MediaSource(url = "thumb", json = ""),
fileSize = null,
formattedFileSize = "15 MB",
fileExtension = "MP4",
),
),
),
)
}
@@ -0,0 +1,275 @@
/*
* 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.foundation.background
import androidx.compose.foundation.border
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.toMutableStateList
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.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.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.api.core.EventId
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView
import io.element.android.libraries.matrix.ui.media.contentvalidation.NotFoundContentView
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectMediaState
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
import io.element.android.libraries.ui.strings.CommonStrings
import kotlin.math.max
@Composable
fun TimelineItemAttachmentsListView(
eventId: EventId?,
content: TimelineItemAttachmentsContent,
onGalleryItemClick: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
val contentValidationState = rememberEventContentValidationState(
eventId = eventId,
needsValidation = content.isMedia,
)
Column(modifier = modifier.fillMaxWidth()) {
val validationStates = remember(content.attachments) {
buildList {
for (attachment in content.attachments) {
add(validationStateForAttachment(attachment, contentValidationState))
}
}.toMutableStateList()
}
content.attachments.forEachIndexed { index, attachment ->
Column(modifier = Modifier.fillMaxWidth()) {
val thumbnailContentValidationState by contentValidationState.collectMediaState(attachment.thumbnailSource?.safeUrl)
val mediaContentValidationState by contentValidationState.collectMediaState(attachment.mediaSource.safeUrl)
val itemContentValidationState = remember(thumbnailContentValidationState, mediaContentValidationState) {
validationStateForAttachment(attachment, contentValidationState)
}
LaunchedEffect(itemContentValidationState) {
validationStates[index] = itemContentValidationState
}
val needsSeparator = index in 1..max(1, content.attachments.lastIndex)
val isPreviousItemInvalid = validationStates.getOrNull(index - 1)?.hasError() == true
if (needsSeparator && !itemContentValidationState.hasError() && !isPreviousItemInvalid) {
HorizontalDivider(
color = ElementTheme.colors.borderInteractiveSecondary,
)
}
when (itemContentValidationState) {
ContentValidationValue.Invalid -> {
val shape = RoundedCornerShape(6.dp)
InvalidContentView(
modifier = Modifier
.padding(vertical = 6.dp)
.border(width = 1.dp, color = ElementTheme.colors.borderCriticalSubtle, shape = shape)
.clip(shape),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
)
}
is ContentValidationValue.UnrecoverableError -> {
val shape = RoundedCornerShape(6.dp)
NotFoundContentView(
modifier = Modifier
.padding(vertical = 6.dp)
.border(width = 1.dp, color = ElementTheme.colors.borderCriticalSubtle, shape = shape)
.clip(shape),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
)
}
ContentValidationValue.Loading -> {
CircularProgressIndicator(modifier = Modifier.padding(8.dp).align(Alignment.CenterHorizontally))
}
ContentValidationValue.Valid -> {
AttachmentListItem(
attachment = attachment,
onClick = { onGalleryItemClick(index) },
)
}
else -> {
// TODO handle cases where either the content hasn't started validation or it failed to do so
}
}
if (index == content.attachments.lastIndex && content.showCaption && !itemContentValidationState.isInvalid()) {
HorizontalDivider(
color = ElementTheme.colors.borderInteractiveSecondary,
)
}
}
}
}
}
private fun validationStateForAttachment(
attachment: AttachmentItem,
contentValidationState: ContentValidationState,
): ContentValidationValue {
val thumbnailValue = attachment.thumbnailSource?.safeUrl?.let { contentValidationState.getCurrentMediaState(it) } ?: ContentValidationValue.Unknown
val mediaValue = contentValidationState.getCurrentMediaState(attachment.mediaSource.safeUrl)
return validationStateForAttachment(thumbnailValue, mediaValue)
}
private fun validationStateForAttachment(
thumbnailValue: ContentValidationValue,
mediaValue: ContentValidationValue,
): ContentValidationValue {
return if (thumbnailValue.isInvalid() || mediaValue.isInvalid()) {
ContentValidationValue.Invalid
} else if (thumbnailValue.hasUnrecoverableError() || mediaValue.hasUnrecoverableError()) {
listOf(thumbnailValue, mediaValue).first { it.hasUnrecoverableError() }
} else if (thumbnailValue.isLoading() || mediaValue.isLoading()) {
ContentValidationValue.Loading
} else {
mediaValue
}
}
@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(
eventId = EventId("\$eventId"),
content = content,
onGalleryItemClick = {},
)
}
@@ -16,11 +16,13 @@ import io.element.android.features.messages.impl.timeline.components.layout.Cont
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContentProvider
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
@Composable
fun TimelineItemAudioView(
content: TimelineItemAudioContent,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
contentValidationValue: ContentValidationValue,
modifier: Modifier = Modifier,
) {
TimelineItemAttachmentView(
@@ -28,9 +30,9 @@ fun TimelineItemAudioView(
iconContentDescription = null,
filename = content.filename,
fileExtensionAndSize = content.fileExtensionAndSize,
caption = content.caption,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier,
contentValidationValue = contentValidationValue,
)
}
@@ -41,5 +43,6 @@ internal fun TimelineItemAudioViewPreview(@PreviewParameter(TimelineItemAudioCon
TimelineItemAudioView(
content,
onContentLayoutChange = {},
contentValidationValue = ContentValidationValue.Valid,
)
}
@@ -8,16 +8,32 @@
package io.element.android.features.messages.impl.timeline.components.event
import android.text.SpannedString
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.TimelineEvent
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.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
@@ -30,110 +46,265 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.captionOrNull
import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation
import io.element.android.features.messages.impl.timeline.model.event.formattedCaptionOrNull
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.components.EqualWidthColumn
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView
import io.element.android.libraries.matrix.ui.media.contentvalidation.NotFoundContentView
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link
@Composable
fun TimelineItemEventContentView(
eventId: EventId?,
content: TimelineItemEventContent,
hideMediaContent: Boolean,
onContentClick: (() -> Unit)?,
onGalleryItemClick: ((Int) -> Unit),
timelineProtectionState: TimelineProtectionState,
onLongClick: (() -> Unit)?,
onShowContentClick: () -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
modifier: Modifier = Modifier,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {},
) {
val presenterFactories = LocalTimelineItemPresenterFactories.current
when (content) {
is TimelineItemEncryptedContent -> TimelineItemEncryptedView(
content = content,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier
)
is TimelineItemRedactedContent -> TimelineItemRedactedView(
content = content,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier
)
is TimelineItemTextBasedContent -> TimelineItemTextView(
content = content,
modifier = modifier,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onContentLayoutChange = onContentLayoutChange
)
is TimelineItemUnknownContent -> TimelineItemUnknownView(
content = content,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier
)
is TimelineItemLocationContent -> TimelineItemLocationView(
content = content,
modifier = modifier
)
is TimelineItemImageContent -> TimelineItemImageView(
content = content,
hideMediaContent = hideMediaContent,
onContentClick = onContentClick,
onLongClick = onLongClick,
onShowContentClick = onShowContentClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier,
)
is TimelineItemStickerContent -> TimelineItemStickerView(
content = content,
hideMediaContent = hideMediaContent,
onContentClick = onContentClick,
onLongClick = onLongClick,
onShowClick = onShowContentClick,
modifier = modifier,
)
is TimelineItemVideoContent -> TimelineItemVideoView(
content = content,
hideMediaContent = hideMediaContent,
onContentClick = onContentClick,
onLongClick = onLongClick,
onShowContentClick = onShowContentClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier
)
is TimelineItemFileContent -> TimelineItemFileView(
content = content,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier
)
is TimelineItemAudioContent -> TimelineItemAudioView(
content = content,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier
)
is TimelineItemLegacyCallInviteContent -> TimelineItemLegacyCallInviteView(modifier = modifier)
is TimelineItemStateContent -> TimelineItemStateView(
content = content,
modifier = modifier
)
is TimelineItemPollContent -> TimelineItemPollView(
content = content,
eventSink = eventSink,
modifier = modifier,
)
is TimelineItemVoiceContent -> {
val presenter: Presenter<VoiceMessageState> = presenterFactories.rememberPresenter(content)
TimelineItemVoiceView(
state = presenter.present(),
content = content,
val hideMediaContent = remember(eventId, timelineProtectionState.protectionState) {
timelineProtectionState.hideMediaContent(eventId)
}
val onShowContentClick = remember(timelineProtectionState.eventSink) {
{
timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(eventId))
}
}
val contentValidationState = rememberEventContentValidationState(eventId = eventId, needsValidation = content.isMedia)
val overallValidationState by contentValidationState.collectOverallState()
val needsContentValidationPerItem = remember(content) { content is TimelineItemGalleryContent || content is TimelineItemAttachmentsContent }
if (eventId != null) {
ValidateMediaHelper(eventId, content, contentValidationState, eventSink)
}
val caption = content.captionOrNull()
val showCaption = caption != null && content !is TimelineItemStickerContent && content !is TimelineItemVoiceContent
// If a caption is added, it will be used to get the free space for the overlay, so we don't need to add onContentLayoutChange to the actual content
val calculatedOnContentLayoutChange = remember(onContentLayoutChange, showCaption) {
if (showCaption) {
{}
} else {
onContentLayoutChange
}
}
val displayInvalidContent = overallValidationState.isInvalid() && !needsContentValidationPerItem
val displayContentNotFound = overallValidationState.hasUnrecoverableError() && !needsContentValidationPerItem
EqualWidthColumn(modifier = modifier) {
if (displayInvalidContent) {
InvalidContentView(
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
onTextLayout = ContentAvoidingLayout.measureLastTextLine(
onContentLayoutChange = calculatedOnContentLayoutChange,
// Icon + horizontal paddings
extraWidth = 24.dp + 20.dp,
)
)
} else if (displayContentNotFound) {
NotFoundContentView(
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
onTextLayout = ContentAvoidingLayout.measureLastTextLine(
onContentLayoutChange = calculatedOnContentLayoutChange,
// Icon + horizontal paddings
extraWidth = 24.dp + 20.dp,
)
)
} else {
val presenterFactories = LocalTimelineItemPresenterFactories.current
when (content) {
is TimelineItemEncryptedContent -> TimelineItemEncryptedView(
content = content,
onContentLayoutChange = calculatedOnContentLayoutChange,
)
is TimelineItemRedactedContent -> TimelineItemRedactedView(
content = content,
onContentLayoutChange = calculatedOnContentLayoutChange,
)
is TimelineItemTextBasedContent -> TimelineItemTextView(
content = content,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onContentLayoutChange = calculatedOnContentLayoutChange,
)
is TimelineItemUnknownContent -> TimelineItemUnknownView(
content = content,
onContentLayoutChange = calculatedOnContentLayoutChange,
)
is TimelineItemLocationContent -> {
TimelineItemLocationView(
content = content.ensureActiveLiveLocation(),
onStopLiveLocationClick = { eventSink(TimelineEvent.StopLiveLocationShare) },
)
}
is TimelineItemImageContent -> {
TimelineItemImageView(
content = content,
hideMediaContent = hideMediaContent,
onContentClick = onContentClick,
onLongClick = onLongClick,
onShowContentClick = onShowContentClick,
contentValidationState = contentValidationState,
)
}
is TimelineItemStickerContent -> {
TimelineItemStickerView(
content = content,
hideMediaContent = hideMediaContent,
onContentClick = onContentClick,
onLongClick = onLongClick,
onShowClick = onShowContentClick,
contentValidationState = contentValidationState,
)
}
is TimelineItemVideoContent -> {
TimelineItemVideoView(
content = content,
hideMediaContent = hideMediaContent,
onContentClick = onContentClick,
onLongClick = onLongClick,
onShowContentClick = onShowContentClick,
contentValidationState = contentValidationState,
)
}
is TimelineItemGalleryContent -> TimelineItemGalleryView(
eventId = eventId,
content = content,
onGalleryItemClick = { index -> onGalleryItemClick(index) },
onLongClick = onLongClick,
)
is TimelineItemAttachmentsContent -> TimelineItemAttachmentsListView(
eventId = eventId,
content = content,
onGalleryItemClick = { index -> onGalleryItemClick(index) },
)
is TimelineItemFileContent -> {
TimelineItemFileView(
content = content,
onContentLayoutChange = calculatedOnContentLayoutChange,
contentValidationValue = overallValidationState,
)
}
is TimelineItemAudioContent -> {
TimelineItemAudioView(
content = content,
onContentLayoutChange = calculatedOnContentLayoutChange,
contentValidationValue = overallValidationState,
)
}
is TimelineItemLegacyCallInviteContent -> TimelineItemLegacyCallInviteView()
is TimelineItemStateContent -> TimelineItemStateView(
content = content,
)
is TimelineItemPollContent -> TimelineItemPollView(
content = content,
eventSink = eventSink,
)
is TimelineItemVoiceContent -> {
val presenter: Presenter<VoiceMessageState> = presenterFactories.rememberPresenter(content)
TimelineItemVoiceView(
state = presenter.present(),
content = content,
onContentLayoutChange = calculatedOnContentLayoutChange,
contentValidationValue = overallValidationState,
)
}
is TimelineItemRtcNotificationContent -> error("This shouldn't be rendered as the content of a bubble")
}
}
if (showCaption) {
val padding = if (displayInvalidContent || displayContentNotFound) {
PaddingValues(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 0.dp)
} else {
when (content) {
is TimelineItemImageContent,
is TimelineItemVideoContent,
is TimelineItemGalleryContent,
is TimelineItemAttachmentsContent -> PaddingValues(start = 4.dp, end = 4.dp, top = 8.dp, bottom = 0.dp)
else -> PaddingValues(horizontal = 12.dp, vertical = 8.dp)
}
}
CaptionView(
modifier = Modifier.padding(padding),
caption = caption,
formattedCaption = content.formattedCaptionOrNull(),
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier
)
}
is TimelineItemRtcNotificationContent -> error("This shouldn't be rendered as the content of a bubble")
}
}
@Composable
private fun CaptionView(
caption: String,
formattedCaption: CharSequence?,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {
val caption = if (LocalInspectionMode.current) {
SpannedString(caption)
} else {
formattedCaption ?: SpannedString(caption)
}
CompositionLocalProvider(
LocalContentColor provides ElementTheme.colors.textPrimary,
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
) {
EditorStyledText(
modifier = modifier,
text = caption,
style = ElementRichTextEditorStyle.textStyle(),
onLinkClickedListener = onLinkClick,
onLinkLongClickedListener = onLinkLongClick,
releaseOnDetach = false,
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
)
}
}
@Composable
private fun ValidateMediaHelper(
eventId: EventId,
content: TimelineItemEventContent,
contentValidationState: ContentValidationState,
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
) {
val mediaSources = when (content) {
is TimelineItemImageContent -> listOfNotNull(content.thumbnailSource, content.mediaSource)
is TimelineItemStickerContent -> listOfNotNull(content.thumbnailSource, content.mediaSource)
is TimelineItemVideoContent -> listOfNotNull(content.thumbnailSource, content.mediaSource)
is TimelineItemFileContent -> listOfNotNull(content.thumbnailSource, content.mediaSource)
is TimelineItemAudioContent -> listOf(content.mediaSource)
is TimelineItemVoiceContent -> listOf(content.mediaSource)
is TimelineItemGalleryContent -> content.items.flatMap { listOfNotNull(it.thumbnailSource, it.mediaSource) }
is TimelineItemAttachmentsContent -> content.attachments.flatMap { listOfNotNull(it.thumbnailSource, it.mediaSource) }
else -> return
}
val updatedEventSink by rememberUpdatedState(eventSink)
LaunchedEffect(eventId, mediaSources) {
updatedEventSink(TimelineEvent.ValidateMedia(eventId, mediaSources, contentValidationState))
}
}
@@ -17,6 +17,7 @@ import io.element.android.features.messages.impl.timeline.components.layout.Cont
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContentProvider
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.ui.strings.CommonStrings
/**
@@ -26,6 +27,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun TimelineItemFileView(
content: TimelineItemFileContent,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
contentValidationValue: ContentValidationValue,
modifier: Modifier = Modifier,
) {
TimelineItemAttachmentView(
@@ -33,9 +35,9 @@ fun TimelineItemFileView(
iconContentDescription = stringResource(CommonStrings.common_file),
filename = content.filename,
fileExtensionAndSize = content.fileExtensionAndSize,
caption = content.caption,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier,
contentValidationValue = contentValidationValue,
)
}
@@ -46,6 +48,7 @@ internal fun TimelineItemFileViewPreview(@PreviewParameter(TimelineItemFileConte
TimelineItemFileView(
content,
onContentLayoutChange = {},
contentValidationValue = ContentValidationValue.Valid,
)
}
}
@@ -0,0 +1,139 @@
/*
* 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.libraries.matrix.api.media.MediaSource
import kotlinx.collections.immutable.toImmutableList
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,
)
},
),
)
}
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",
type: GalleryItem.Type = GalleryItem.Type.Image,
width: Int = 400,
height: Int = 300,
duration: Duration = Duration.ZERO,
mediaSource: MediaSource = MediaSource(url = "", json = ""),
): GalleryItem {
return 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,
thumbnailSource = null,
width = width,
height = height,
thumbnailWidth = width,
thumbnailHeight = height,
blurhash = null,
type = type,
duration = duration,
)
}
@@ -0,0 +1,401 @@
/*
* 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.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.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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.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.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.components.Icon
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectMediaState
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
import io.element.android.libraries.ui.utils.time.formatShort
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(
eventId: EventId?,
content: TimelineItemGalleryContent,
onGalleryItemClick: (Int) -> Unit,
onLongClick: (() -> 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(
eventId = eventId,
item = content.items[0],
onClick = { onGalleryItemClick(0) },
onLongClick = onLongClick,
)
2 -> TwoItemLayout(
eventId = eventId,
items = content.items,
onItemClick = onGalleryItemClick,
onLongClick = onLongClick,
)
3 -> ThreeItemLayout(
eventId = eventId,
items = content.items,
onItemClick = onGalleryItemClick,
onLongClick = onLongClick,
)
else -> FourPlusItemLayout(
eventId = eventId,
items = content.items,
showOverflow = showOverflow,
overflowCount = overflowCount,
onItemClick = onGalleryItemClick,
onLongClick = onLongClick,
)
}
}
}
}
@Composable
private fun SingleItemLayout(
eventId: EventId?,
item: GalleryItem,
onClick: () -> Unit,
onLongClick: (() -> Unit)?,
) {
GalleryItemCell(
eventId = eventId,
item = item,
isLast = false,
remainingCount = 0,
onClick = onClick,
onLongClick = onLongClick,
modifier = Modifier
.width(GALLERY_WIDTH)
.height(SINGLE_IMAGE_HEIGHT),
)
}
@Composable
private fun TwoItemLayout(
eventId: EventId?,
items: ImmutableList<GalleryItem>,
onItemClick: (Int) -> Unit,
onLongClick: (() -> Unit)?,
) {
Column(
modifier = Modifier.width(GALLERY_WIDTH),
verticalArrangement = Arrangement.spacedBy(GRID_SPACING),
) {
items.forEachIndexed { index, item ->
GalleryItemCell(
eventId = eventId,
item = item,
isLast = false,
remainingCount = 0,
onClick = { onItemClick(index) },
onLongClick = onLongClick,
modifier = Modifier
.fillMaxWidth()
.height(TWO_IMAGE_ROW_HEIGHT),
)
}
}
}
@Composable
private fun ThreeItemLayout(
eventId: EventId?,
items: ImmutableList<GalleryItem>,
onItemClick: (Int) -> Unit,
onLongClick: (() -> Unit)?,
) {
Column(
modifier = Modifier.width(GALLERY_WIDTH),
verticalArrangement = Arrangement.spacedBy(GRID_SPACING),
) {
GalleryItemCell(
eventId = eventId,
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(
eventId = eventId,
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(
eventId: EventId?,
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(
eventId = eventId,
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(
eventId = eventId,
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(
eventId: EventId?,
item: GalleryItem,
isLast: Boolean,
remainingCount: Int,
onClick: () -> Unit,
onLongClick: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
val eventContentValidationState = rememberEventContentValidationState(eventId, needsValidation = true)
val thumbnailContentValidationState by eventContentValidationState.collectMediaState(item.thumbnailSource?.safeUrl)
val mediaContentValidationState by eventContentValidationState.collectMediaState(item.mediaSource.safeUrl)
val itemContentValidationState = remember(thumbnailContentValidationState, mediaContentValidationState) {
if (thumbnailContentValidationState.isInvalid() || mediaContentValidationState.isInvalid()) {
ContentValidationValue.Invalid
} else if (thumbnailContentValidationState.hasUnrecoverableError() || mediaContentValidationState.hasUnrecoverableError()) {
listOf(thumbnailContentValidationState, mediaContentValidationState).first { it is ContentValidationValue.UnrecoverableError }
} else if (thumbnailContentValidationState.isLoading() || mediaContentValidationState.isLoading()) {
ContentValidationValue.Loading
} else {
mediaContentValidationState
}
}
Box(
modifier = modifier
.blurHashBackground(item.blurhash, alpha = 0.9f)
.then(
if (itemContentValidationState.isValid()) {
Modifier.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
)
} else {
Modifier
}
),
contentAlignment = Alignment.Center,
) {
AsyncImage(
modifier = Modifier.fillMaxSize(),
model = item.thumbnailMediaRequestData,
contentScale = ContentScale.Crop,
alignment = Alignment.Center,
contentDescription = item.filename,
)
if (item.type == GalleryItem.Type.Video) {
VideoOverlay(duration = item.duration)
}
if (itemContentValidationState.isLoading()) {
CircularProgressIndicator()
} else if (itemContentValidationState.hasError()) {
Box(
modifier = Modifier.fillMaxSize().background(ElementTheme.colors.bgCriticalSubtle)
) {
Icon(
modifier = Modifier.align(Alignment.Center),
imageVector = CompoundIcons.Error(),
tint = ElementTheme.colors.iconCriticalPrimary,
contentDescription = null,
)
}
} else 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(
eventId = null,
content = content,
onGalleryItemClick = {},
onLongClick = {},
)
}
@@ -8,18 +8,12 @@
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.Column
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.widthIn
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
@@ -31,32 +25,31 @@ 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.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.components.ATimelineItemEventRow
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.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.protection.ProtectedView
import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent
import io.element.android.features.messages.impl.timeline.util.handleAsyncImageStateChange
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
@Composable
fun TimelineItemImageView(
@@ -64,77 +57,67 @@ fun TimelineItemImageView(
hideMediaContent: Boolean,
onContentClick: (() -> Unit)?,
onLongClick: (() -> Unit)?,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onShowContentClick: () -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
contentValidationState: ContentValidationState,
modifier: Modifier = Modifier,
) {
val a11yLabel = stringResource(CommonStrings.common_image)
val description = content.caption?.let { "$a11yLabel: $it" } ?: a11yLabel
Column(modifier = modifier) {
Column(modifier = modifier.wrapContentWidth(Alignment.CenterHorizontally)) {
val containerModifier = if (content.showCaption) {
Modifier.clip(RoundedCornerShape(10.dp))
} else {
Modifier
}
val eventContentValidation by contentValidationState.collectOverallState()
val isContentBeingValidated = !eventContentValidation.isValidated()
TimelineItemAspectRatioBox(
modifier = containerModifier.blurHashBackground(content.blurhash, alpha = 0.9f),
modifier = containerModifier
.blurHashBackground(content.blurhash, alpha = 0.9f)
.align(Alignment.CenterHorizontally),
aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent),
) {
ProtectedView(
hideContent = hideMediaContent,
onShowClick = onShowContentClick,
) {
var isLoaded by remember { mutableStateOf(false) }
AsyncImage(
modifier = Modifier
.fillMaxWidth()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier)
.then(
if (!isTalkbackActive() && onContentClick != null) {
Modifier
.combinedClickable(
onClick = onContentClick,
onLongClick = onLongClick,
)
.onKeyboardContextMenuAction(onLongClick)
} else {
Modifier
}
),
model = content.thumbnailMediaRequestData,
contentScale = ContentScale.Crop,
alignment = Alignment.Center,
contentDescription = description,
onState = { isLoaded = it is AsyncImagePainter.State.Success },
)
}
}
if (content.showCaption) {
Spacer(modifier = Modifier.height(8.dp))
val caption = if (LocalInspectionMode.current) {
SpannedString(content.caption)
if (isContentBeingValidated) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else {
content.formattedCaption ?: SpannedString(content.caption)
}
CompositionLocalProvider(
LocalContentColor provides ElementTheme.colors.textPrimary,
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
) {
val aspectRatio = content.aspectRatio ?: DEFAULT_ASPECT_RATIO
EditorStyledText(
modifier = Modifier
.padding(horizontal = 4.dp) // This is (12.dp - 8.dp) contentPadding from CommonLayout
.widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio),
text = caption,
style = ElementRichTextEditorStyle.textStyle(),
onLinkClickedListener = onLinkClick,
onLinkLongClickedListener = onLinkLongClick,
releaseOnDetach = false,
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
)
ProtectedView(
hideContent = hideMediaContent,
onShowClick = onShowContentClick,
) {
var isLoaded by remember { mutableStateOf(false) }
AsyncImage(
modifier = Modifier
.fillMaxWidth()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier)
.then(
if (!isTalkbackActive() && onContentClick != null) {
Modifier
.combinedClickable(
onClick = onContentClick,
onLongClick = onLongClick,
)
.onKeyboardContextMenuAction(onLongClick)
} else {
Modifier
}
),
model = content.thumbnailMediaRequestData,
contentScale = ContentScale.Crop,
alignment = Alignment.Center,
contentDescription = description,
onState = { state ->
val url = content.thumbnailMediaRequestData.source?.safeUrl
if (url != null) {
handleAsyncImageStateChange(
state = state,
onLoaded = { isLoaded = true },
updateContentValidationState = { contentValidationState.update(url, it) },
)
}
},
)
}
}
}
}
@@ -149,54 +132,56 @@ internal fun TimelineItemImageViewPreview(@PreviewParameter(TimelineItemImageCon
onShowContentClick = {},
onContentClick = {},
onLongClick = {},
onLinkClick = {},
onLinkLongClick = {},
onContentLayoutChange = {},
contentValidationState = NoopContentValidationState(),
)
}
@PreviewsDayNight
@Composable
internal fun TimelineItemImageViewHideMediaContentPreview() = ElementPreview {
TimelineItemImageView(
content = aTimelineItemImageContent(),
hideMediaContent = true,
onShowContentClick = {},
onContentClick = {},
onLongClick = {},
onLinkClick = {},
onLinkLongClick = {},
onContentLayoutChange = {},
)
TimelineItemImageView(
content = aTimelineItemImageContent(),
hideMediaContent = true,
onShowContentClick = {},
onContentClick = {},
onLongClick = {},
contentValidationState = NoopContentValidationState(),
)
}
@PreviewsDayNight
@Composable
internal fun TimelineImageWithCaptionRowPreview() = ElementPreview {
Column {
sequenceOf(false, true).forEach { isMine ->
internal fun ATimelineItemEventRowPreview() = ElementPreview {
CompositionLocalProvider(LocalEventContentValidationState provides NoopEventContentValidationCache()) {
Column {
sequenceOf(false, true).forEach { isMine ->
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = isMine,
content = aTimelineItemImageContent(
filename = "image.jpg",
caption = "A long caption that may wrap into several lines",
width = 40,
height = 20,
aspectRatio = 40f / 20f,
),
groupPosition = TimelineItemGroupPosition.Last,
),
)
}
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = isMine,
isMine = false,
content = aTimelineItemImageContent(
filename = "image.jpg",
caption = "A long caption that may wrap into several lines",
aspectRatio = 2.5f,
caption = "Narrow image with null aspectRatio",
width = 80,
height = 150,
aspectRatio = null,
),
groupPosition = TimelineItemGroupPosition.Last,
),
)
}
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = false,
content = aTimelineItemImageContent(
filename = "image.jpg",
caption = "Image with null aspectRatio",
aspectRatio = null,
),
groupPosition = TimelineItemGroupPosition.Last,
),
)
}
}
@@ -8,33 +8,154 @@
package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.location.api.StaticMapView
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContentProvider
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
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.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun TimelineItemLocationView(
content: TimelineItemLocationContent,
onStopLiveLocationClick: () -> Unit,
modifier: Modifier = Modifier,
) {
StaticMapView(
Box(modifier = modifier.fillMaxWidth()) {
StaticMapView(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 188.dp),
pinVariant = content.pinVariant,
location = content.location,
zoom = 15.0,
contentDescription = content.description
)
if (content.mode is TimelineItemLocationContent.Mode.Live) {
LiveLocationOverlay(
mode = content.mode,
onStopClick = onStopLiveLocationClick,
modifier = Modifier.align(Alignment.BottomStart)
)
}
}
}
@Composable
private fun LiveLocationOverlay(
mode: TimelineItemLocationContent.Mode.Live,
onStopClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.heightIn(max = 188.dp),
pinVariant = content.pinVariant,
lat = content.location.lat,
lon = content.location.lon,
zoom = 15.0,
contentDescription = content.body
)
.background(ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.9f)),
verticalAlignment = Alignment.CenterVertically,
) {
val iconShape = RoundedCornerShape(8.dp)
Box(
modifier = Modifier
// Ensure this Box uses same spacings than the Stop IconButton.
.minimumInteractiveComponentSize()
.size(32.dp)
.border(
width = 1.dp,
color = if (mode.isActive) ElementTheme.colors.iconQuaternaryAlpha else Color.Transparent,
shape = iconShape,
)
.background(
color = if (mode.isActive) {
ElementTheme.colors.bgCanvasDefault
} else {
ElementTheme.colors.bgSubtleSecondary
},
shape = iconShape
)
) {
if (mode.isLoading) {
CircularProgressIndicator(
strokeWidth = 2.dp,
color = ElementTheme.colors.iconSecondary,
modifier = Modifier
.align(Alignment.Center)
.size(20.dp)
)
} else {
Icon(
imageVector = CompoundIcons.LocationPinSolid(),
contentDescription = null,
tint = if (mode.isActive) {
ElementTheme.colors.iconAccentPrimary
} else {
ElementTheme.colors.iconDisabled
},
modifier = Modifier.align(Alignment.Center)
)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = if (mode.isActive) {
stringResource(CommonStrings.common_live_location)
} else {
stringResource(CommonStrings.common_live_location_ended)
},
style = ElementTheme.typography.fontBodySmMedium,
color = ElementTheme.colors.textPrimary,
)
if (mode.isActive) {
Text(
text = mode.endsAt,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textPrimary,
)
}
}
if (mode.canStopSharing) {
IconButton(
onClick = onStopClick,
colors = IconButtonDefaults.iconButtonColors(
containerColor = ElementTheme.colors.bgCriticalPrimary,
contentColor = ElementTheme.colors.iconOnSolidPrimary,
),
modifier = Modifier
.minimumInteractiveComponentSize()
.size(30.dp)
) {
Icon(
imageVector = CompoundIcons.Stop(),
contentDescription = null,
)
}
}
}
}
@PreviewsDayNight
@@ -43,5 +164,6 @@ internal fun TimelineItemLocationViewPreview(@PreviewParameter(TimelineItemLocat
ElementPreview {
TimelineItemLocationView(
content = content,
onStopLiveLocationClick = {},
)
}
@@ -12,6 +12,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -26,16 +27,20 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContentProvider
import io.element.android.features.messages.impl.timeline.protection.ProtectedView
import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent
import io.element.android.features.messages.impl.timeline.util.handleAsyncImageStateChange
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
import io.element.android.libraries.ui.strings.CommonStrings
private const val STICKER_SIZE_IN_DP = 128
@@ -47,11 +52,15 @@ fun TimelineItemStickerView(
onContentClick: (() -> Unit)?,
onLongClick: (() -> Unit)?,
onShowClick: () -> Unit,
contentValidationState: ContentValidationState,
modifier: Modifier = Modifier,
) {
val description = content.bestDescription.takeIf { it.isNotEmpty() } ?: stringResource(CommonStrings.common_image)
val eventContentValidation by contentValidationState.collectOverallState()
val isContentBeingValidated = !eventContentValidation.isValidated()
Column(
modifier = modifier.semantics { contentDescription = description },
modifier = modifier.semantics { contentDescription = description }.wrapContentWidth(Alignment.CenterHorizontally),
) {
TimelineItemAspectRatioBox(
modifier = Modifier.blurHashBackground(content.blurhash, alpha = 0.9f),
@@ -63,11 +72,12 @@ fun TimelineItemStickerView(
hideContent = hideMediaContent,
onShowClick = onShowClick,
) {
var isLoaded by remember { mutableStateOf(false) }
var isThumbnailLoaded by remember { mutableStateOf(false) }
AsyncImage(
modifier = Modifier
.fillMaxSize()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier)
.then(if (isThumbnailLoaded) Modifier.background(Color.White) else Modifier)
.then(
if (onContentClick != null) {
Modifier
@@ -91,9 +101,22 @@ fun TimelineItemStickerView(
contentScale = ContentScale.Crop,
alignment = Alignment.Center,
contentDescription = description,
onState = { isLoaded = it is AsyncImagePainter.State.Success },
onState = { state ->
val url = content.preferredMediaSource?.safeUrl
if (url != null) {
handleAsyncImageStateChange(
state = state,
onLoaded = { isThumbnailLoaded = true },
updateContentValidationState = { contentValidationState.update(url, it) },
)
}
},
)
}
if (isContentBeingValidated) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
}
}
@@ -107,5 +130,6 @@ internal fun TimelineItemStickerViewPreview(@PreviewParameter(TimelineItemSticke
onContentClick = {},
onLongClick = {},
onShowClick = {},
contentValidationState = NoopContentValidationState(),
)
}
@@ -30,6 +30,7 @@ import io.element.android.features.messages.impl.utils.containsOnlyEmojis
import io.element.android.libraries.androidutils.text.LinkifyHelper
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.utils.LocalUiTestMode
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater
import io.element.android.wysiwyg.compose.EditorStyledText
@@ -43,6 +44,9 @@ fun TimelineItemTextView(
modifier: Modifier = Modifier,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {},
) {
// The View <-> Compose interop is not working well with Compose UI tests (it loops indefinitely), so we skip it in the UI test mode.
if (LocalUiTestMode.current) return
val emojiOnly = content.formattedBody.toString() == content.body &&
content.body.replace(" ", "").containsOnlyEmojis()
val textStyle = when {
@@ -8,22 +8,17 @@
package io.element.android.features.messages.impl.timeline.components.event
import android.text.SpannedString
import androidx.compose.foundation.Image
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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -34,26 +29,19 @@ import androidx.compose.ui.draw.clip
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.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.components.ATimelineItemEventRow
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.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContentProvider
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.protection.ProtectedView
import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent
import io.element.android.features.messages.impl.timeline.util.handleAsyncImageStateChange
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
@@ -62,11 +50,11 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
@Composable
fun TimelineItemVideoView(
@@ -75,15 +63,13 @@ fun TimelineItemVideoView(
onContentClick: (() -> Unit)?,
onLongClick: (() -> Unit)?,
onShowContentClick: () -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
contentValidationState: ContentValidationState,
modifier: Modifier = Modifier,
) {
val isTalkbackActive = isTalkbackActive()
val a11yLabel = stringResource(CommonStrings.common_video)
val description = content.caption?.let { "$a11yLabel: $it" } ?: a11yLabel
Column(modifier = modifier) {
Column(modifier = modifier.wrapContentWidth(Alignment.CenterHorizontally)) {
val containerModifier = if (content.showCaption) {
Modifier
.padding(top = 6.dp)
@@ -91,6 +77,9 @@ fun TimelineItemVideoView(
} else {
Modifier
}
val eventContentValidation by contentValidationState.collectOverallState()
val isContentBeingValidated = !eventContentValidation.isValidated()
TimelineItemAspectRatioBox(
modifier = containerModifier.blurHashBackground(content.blurHash, alpha = 0.9f),
aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent),
@@ -127,48 +116,35 @@ fun TimelineItemVideoView(
contentScale = ContentScale.Crop,
alignment = Alignment.Center,
contentDescription = description,
onState = { isLoaded = it is AsyncImagePainter.State.Success },
onState = { state ->
val url = content.thumbnailSource?.safeUrl
if (url != null) {
handleAsyncImageStateChange(
state = state,
onLoaded = { isLoaded = true },
updateContentValidationState = { contentValidationState.update(url, it) },
)
}
},
)
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 (!isContentBeingValidated) {
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() }
)
}
} else {
CircularProgressIndicator()
}
}
}
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,
) {
val aspectRatio = content.aspectRatio ?: DEFAULT_ASPECT_RATIO
EditorStyledText(
modifier = Modifier
.padding(horizontal = 4.dp) // This is (12.dp - 8.dp) contentPadding from CommonLayout
.widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio),
text = caption,
onLinkClickedListener = onLinkClick,
onLinkLongClickedListener = onLinkLongClick,
style = ElementRichTextEditorStyle.textStyle(),
releaseOnDetach = false,
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
)
}
}
}
}
@@ -181,9 +157,7 @@ internal fun TimelineItemVideoViewPreview(@PreviewParameter(TimelineItemVideoCon
onShowContentClick = {},
onContentClick = {},
onLongClick = {},
onLinkClick = {},
onLinkLongClick = {},
onContentLayoutChange = {},
contentValidationState = NoopContentValidationState(),
)
}
@@ -196,39 +170,6 @@ internal fun TimelineItemVideoViewHideMediaContentPreview() = ElementPreview {
onShowContentClick = {},
onContentClick = {},
onLongClick = {},
onLinkClick = {},
onLinkLongClick = {},
onContentLayoutChange = {},
contentValidationState = NoopContentValidationState(),
)
}
@PreviewsDayNight
@Composable
internal fun TimelineVideoWithCaptionRowPreview() = ElementPreview {
Column {
sequenceOf(false, true).forEach { isMine ->
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = isMine,
content = aTimelineItemVideoContent().copy(
filename = "video.mp4",
caption = "A long caption that may wrap into several lines",
aspectRatio = 2.5f,
),
groupPosition = TimelineItemGroupPosition.Last,
),
)
}
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = false,
content = aTimelineItemVideoContent().copy(
filename = "video.mp4",
caption = "Video with null aspect ratio",
aspectRatio = null,
),
groupPosition = TimelineItemGroupPosition.Last,
),
)
}
}

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