mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-06 16:43:43 +00:00
Merge branch 'develop' into feat/polls-max-selections
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
+2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+352
-30
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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()
|
||||
}
|
||||
|
||||
+61
-4
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
+47
-13
@@ -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))
|
||||
|
||||
+9
@@ -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. */
|
||||
|
||||
+14
-5
@@ -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,
|
||||
)
|
||||
|
||||
+121
-34
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
+10
-4
@@ -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) {
|
||||
|
||||
+3
-2
@@ -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",
|
||||
|
||||
+25
-7
@@ -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,
|
||||
|
||||
+8
-1
@@ -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
|
||||
}
|
||||
|
||||
+44
@@ -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
|
||||
}
|
||||
}
|
||||
+12
@@ -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
|
||||
}
|
||||
|
||||
+3
-2
@@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@ContributesNode(RoomScope::class)
|
||||
@AssistedInject
|
||||
@@ -42,7 +43,7 @@ class AttachmentsPreviewNode(
|
||||
private val enterpriseService: EnterpriseService,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
data class Inputs(
|
||||
val attachment: Attachment,
|
||||
val attachments: ImmutableList<Attachment>,
|
||||
val timelineMode: Timeline.Mode,
|
||||
val inReplyToEventId: EventId?,
|
||||
) : NodeInputs
|
||||
@@ -54,7 +55,7 @@ class AttachmentsPreviewNode(
|
||||
}
|
||||
|
||||
private val presenter = presenterFactory.create(
|
||||
attachment = inputs.attachment,
|
||||
attachments = inputs.attachments,
|
||||
timelineMode = inputs.timelineMode,
|
||||
onDoneListener = onDoneListener,
|
||||
inReplyToEventId = inputs.inReplyToEventId,
|
||||
|
||||
+343
-120
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
+19
-9
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+50
-5
@@ -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,
|
||||
|
||||
+253
-66
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+32
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+202
@@ -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
|
||||
}
|
||||
+197
@@ -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
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.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,
|
||||
)
|
||||
+691
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+22
-19
@@ -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")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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?,
|
||||
|
||||
+31
@@ -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")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
-1
@@ -30,7 +30,6 @@ interface VideoMetadataExtractor : AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
@AssistedInject
|
||||
class DefaultVideoMetadataExtractor(
|
||||
@ApplicationContext private val context: Context,
|
||||
|
||||
+2
-1
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
+4
-1
@@ -35,12 +35,15 @@ internal fun MessagesViewWithIdentityChangePreview(
|
||||
onBackClick = {},
|
||||
onRoomDetailsClick = {},
|
||||
onEventContentClick = { _, _ -> false },
|
||||
onGalleryEventItemClick = { _, _, _ -> false },
|
||||
onUserDataClick = {},
|
||||
onLinkClick = { _, _ -> },
|
||||
onSendLocationClick = {},
|
||||
onCreatePollClick = {},
|
||||
onJoinCallClick = {},
|
||||
onViewAllPinnedMessagesClick = {},
|
||||
knockRequestsBannerView = {}
|
||||
knockRequestsBannerView = {},
|
||||
customReactionBottomSheet = {},
|
||||
onThreadsListClick = {},
|
||||
)
|
||||
}
|
||||
|
||||
+3
-2
@@ -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,
|
||||
)
|
||||
|
||||
+7
-2
@@ -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),
|
||||
|
||||
+20
-21
@@ -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 = {},
|
||||
|
||||
+1
@@ -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
|
||||
}
|
||||
|
||||
+161
-18
@@ -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) {
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
|
||||
+3
@@ -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,
|
||||
)
|
||||
|
||||
+8
@@ -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
|
||||
|
||||
+74
-21
@@ -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 = {}
|
||||
)
|
||||
|
||||
+16
-2
@@ -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
|
||||
|
||||
+7
-1
@@ -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))
|
||||
|
||||
+2
@@ -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
|
||||
}
|
||||
|
||||
+2
@@ -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)
|
||||
}
|
||||
|
||||
+17
-2
@@ -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 = {
|
||||
|
||||
+3
-2
@@ -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)
|
||||
|
||||
|
||||
+26
-7
@@ -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 = {},
|
||||
|
||||
+47
-9
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
+17
@@ -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,
|
||||
)
|
||||
+44
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+145
@@ -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
|
||||
}
|
||||
+412
@@ -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,
|
||||
)
|
||||
+12
@@ -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
|
||||
}
|
||||
|
||||
+117
-26
@@ -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
|
||||
}
|
||||
|
||||
+17
-1
@@ -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
|
||||
}
|
||||
|
||||
+29
-6
@@ -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(),
|
||||
|
||||
+317
-38
@@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -50,6 +50,7 @@ internal fun TimelineViewMessageShieldPreview() = ElementPreview {
|
||||
onMoreReactionsClick = {},
|
||||
onReadReceiptClick = {},
|
||||
onJoinCallClick = {},
|
||||
onGalleryItemClick = { _, _ -> },
|
||||
forceJumpToBottomVisibility = true,
|
||||
)
|
||||
}
|
||||
|
||||
+1
-2
@@ -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 = {},
|
||||
|
||||
+378
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -11,5 +11,6 @@ package io.element.android.features.messages.impl.timeline.components
|
||||
enum class ContentPadding {
|
||||
Textual,
|
||||
Media,
|
||||
CaptionedMedia
|
||||
CaptionedMedia,
|
||||
InvalidContent,
|
||||
}
|
||||
|
||||
+149
@@ -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")
|
||||
}
|
||||
}
|
||||
+13
-2
@@ -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(
|
||||
|
||||
+3
-1
@@ -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,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
+45
-18
@@ -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))
|
||||
|
||||
+112
-63
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+346
@@ -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
|
||||
)
|
||||
)
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.messages.impl.timeline.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",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
+324
-63
@@ -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,
|
||||
)
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemEventRowWithGalleryPreview(
|
||||
@PreviewParameter(TimelineItemEventContentForGalleryViewProvider::class) content: TimelineItemEventContent,
|
||||
) = ElementPreview {
|
||||
Column {
|
||||
sequenceOf(false, true).forEach { isMine ->
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
isMine = isMine,
|
||||
content = content,
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -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(
|
||||
|
||||
-3
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+51
-27
@@ -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
-17
@@ -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,
|
||||
|
||||
+7
-5
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
|
||||
+6
-1
@@ -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 {
|
||||
/**
|
||||
|
||||
+33
-34
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+34
-36
@@ -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
|
||||
}
|
||||
|
||||
+2
-4
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
-100
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
-48
@@ -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
|
||||
}
|
||||
-149
@@ -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(),
|
||||
)
|
||||
}
|
||||
-13
@@ -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
|
||||
}
|
||||
-114
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
-37
@@ -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>,
|
||||
)
|
||||
-84
@@ -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,
|
||||
)
|
||||
+22
-42
@@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
+75
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
+275
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+4
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+258
-87
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+139
@@ -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,
|
||||
)
|
||||
}
|
||||
+401
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+88
-103
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+130
-8
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
+29
-5
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
+4
@@ -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 {
|
||||
|
||||
+38
-97
@@ -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
Reference in New Issue
Block a user