mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-05 04:15:12 +00:00
Merge branch 'develop' into feat/skin-tone-picker
This commit is contained in:
+282
-23
@@ -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
|
||||
@@ -29,6 +32,7 @@ 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
|
||||
@@ -43,9 +47,12 @@ import io.element.android.features.messages.impl.threads.list.ThreadsListNode
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.GalleryItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
@@ -53,6 +60,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||
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
|
||||
@@ -81,6 +89,8 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
|
||||
import io.element.android.libraries.matrix.ui.messages.RoomNamesCache
|
||||
import io.element.android.libraries.mediaviewer.api.GalleryInfo
|
||||
import io.element.android.libraries.mediaviewer.api.GalleryItemData
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater
|
||||
@@ -121,6 +131,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(),
|
||||
@@ -147,7 +158,16 @@ class MessagesFlowNode(
|
||||
) : 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
|
||||
@@ -184,10 +204,15 @@ class MessagesFlowNode(
|
||||
|
||||
@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(
|
||||
@@ -228,7 +253,11 @@ class MessagesFlowNode(
|
||||
callback.navigateToRoomDetails()
|
||||
}
|
||||
|
||||
override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean {
|
||||
override fun handleEventClick(
|
||||
timelineMode: Timeline.Mode,
|
||||
event: TimelineItem.Event,
|
||||
canUseOverlay: Boolean,
|
||||
): Boolean {
|
||||
return processEventClick(
|
||||
timelineMode = timelineMode,
|
||||
event = event,
|
||||
@@ -236,10 +265,24 @@ class MessagesFlowNode(
|
||||
)
|
||||
}
|
||||
|
||||
override fun handleGalleryItemClick(
|
||||
timelineMode: Timeline.Mode,
|
||||
event: TimelineItem.Event,
|
||||
galleryItemIndex: Int,
|
||||
canUseOverlay: Boolean,
|
||||
): Boolean {
|
||||
return processGalleryEventClick(
|
||||
timelineMode = timelineMode,
|
||||
event = event,
|
||||
canUseOverlay = canUseOverlay,
|
||||
galleryItemIndex = galleryItemIndex,
|
||||
)
|
||||
}
|
||||
|
||||
override fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) {
|
||||
backstack.push(
|
||||
NavTarget.AttachmentPreview(
|
||||
attachment = attachments.first(),
|
||||
attachments = attachments,
|
||||
timelineMode = Timeline.Mode.Live,
|
||||
inReplyToEventId = inReplyToEventId,
|
||||
)
|
||||
@@ -267,7 +310,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() {
|
||||
@@ -279,7 +326,11 @@ class MessagesFlowNode(
|
||||
}
|
||||
|
||||
override fun navigateToCurrentLiveLocation() {
|
||||
backstack.push(NavTarget.LocationViewer(ShowLocationMode.Live(senderId = sessionId)))
|
||||
if (hasVulkanSupport()) {
|
||||
backstack.push(NavTarget.LocationViewer(ShowLocationMode.Live(senderId = sessionId)))
|
||||
} else {
|
||||
displayVulkanNotSupportedError = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) {
|
||||
@@ -311,18 +362,53 @@ class MessagesFlowNode(
|
||||
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,
|
||||
)
|
||||
val callback = object : MediaViewerEntryPoint.Callback {
|
||||
override fun onDone() {
|
||||
if (navTarget.canUseOverlay) {
|
||||
overlay.hide()
|
||||
} else {
|
||||
backstack.pop()
|
||||
}
|
||||
}
|
||||
|
||||
override fun viewInTimeline(eventId: EventId) {
|
||||
this@MessagesFlowNode.viewInTimeline(eventId)
|
||||
}
|
||||
|
||||
override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) {
|
||||
// Need to go to the parent because of the overlay
|
||||
callback.forwardEvent(eventId, fromPinnedEvents)
|
||||
}
|
||||
}
|
||||
mediaViewerEntryPoint.createNode(
|
||||
parentNode = this,
|
||||
buildContext = buildContext,
|
||||
params = params,
|
||||
callback = callback
|
||||
)
|
||||
}
|
||||
is NavTarget.GalleryViewer -> {
|
||||
val params = MediaViewerEntryPoint.Params.EventGallery(
|
||||
eventId = navTarget.eventId,
|
||||
galleryInfo = navTarget.galleryInfo,
|
||||
galleryItems = navTarget.galleryItems,
|
||||
fromPinnedMessages = navTarget.fromPinnedMessages,
|
||||
)
|
||||
val callback = object : MediaViewerEntryPoint.Callback {
|
||||
override fun onDone() {
|
||||
@@ -351,7 +437,7 @@ class MessagesFlowNode(
|
||||
}
|
||||
is NavTarget.AttachmentPreview -> {
|
||||
val inputs = AttachmentsPreviewNode.Inputs(
|
||||
attachment = navTarget.attachment,
|
||||
attachments = navTarget.attachments,
|
||||
timelineMode = navTarget.timelineMode,
|
||||
inReplyToEventId = navTarget.inReplyToEventId,
|
||||
)
|
||||
@@ -432,6 +518,19 @@ class MessagesFlowNode(
|
||||
)
|
||||
}
|
||||
|
||||
override fun handleGalleryItemClick(
|
||||
event: TimelineItem.Event,
|
||||
galleryItemIndex: Int,
|
||||
canUseOverlay: Boolean,
|
||||
) {
|
||||
processGalleryEventClick(
|
||||
timelineMode = Timeline.Mode.PinnedEvents,
|
||||
event = event,
|
||||
galleryItemIndex = galleryItemIndex,
|
||||
canUseOverlay = canUseOverlay,
|
||||
)
|
||||
}
|
||||
|
||||
override fun navigateToRoomMemberDetails(userId: UserId) {
|
||||
callback.navigateToRoomMemberDetails(userId)
|
||||
}
|
||||
@@ -467,7 +566,11 @@ class MessagesFlowNode(
|
||||
focusedEventId = navTarget.focusedEventId,
|
||||
)
|
||||
val callback = object : ThreadedMessagesNode.Callback {
|
||||
override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean {
|
||||
override fun handleEventClick(
|
||||
timelineMode: Timeline.Mode,
|
||||
event: TimelineItem.Event,
|
||||
canUseOverlay: Boolean,
|
||||
): Boolean {
|
||||
return processEventClick(
|
||||
timelineMode = timelineMode,
|
||||
event = event,
|
||||
@@ -475,10 +578,24 @@ class MessagesFlowNode(
|
||||
)
|
||||
}
|
||||
|
||||
override fun handleGalleryItemClick(
|
||||
timelineMode: Timeline.Mode,
|
||||
event: TimelineItem.Event,
|
||||
galleryItemIndex: Int,
|
||||
canUseOverlay: Boolean,
|
||||
): Boolean {
|
||||
return processGalleryEventClick(
|
||||
timelineMode = timelineMode,
|
||||
event = event,
|
||||
canUseOverlay = canUseOverlay,
|
||||
galleryItemIndex = galleryItemIndex,
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -506,7 +623,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() {
|
||||
@@ -518,7 +639,11 @@ class MessagesFlowNode(
|
||||
}
|
||||
|
||||
override fun navigateToCurrentLiveLocation() {
|
||||
backstack.push(NavTarget.LocationViewer(ShowLocationMode.Live(senderId = sessionId)))
|
||||
if (hasVulkanSupport()) {
|
||||
backstack.push(NavTarget.LocationViewer(ShowLocationMode.Live(senderId = sessionId)))
|
||||
} else {
|
||||
displayVulkanNotSupportedError = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) {
|
||||
@@ -538,6 +663,10 @@ class MessagesFlowNode(
|
||||
override fun navigateToDeveloperSettings() {
|
||||
callback.navigateToDeveloperSettings()
|
||||
}
|
||||
|
||||
override fun navigateToAvatarPreview(username: String, avatarUrl: String) {
|
||||
overlay.show(NavTarget.AvatarPreview(username, avatarUrl))
|
||||
}
|
||||
}
|
||||
createNode<ThreadedMessagesNode>(buildContext, listOf(inputs, callback))
|
||||
}
|
||||
@@ -549,6 +678,31 @@ class MessagesFlowNode(
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -607,18 +761,23 @@ class MessagesFlowNode(
|
||||
)
|
||||
}
|
||||
is TimelineItemLocationContent -> {
|
||||
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,
|
||||
)
|
||||
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
|
||||
}
|
||||
NavTarget.LocationViewer(mode = mode).takeIf { locationService.isServiceAvailable() }
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
@@ -639,6 +798,92 @@ 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(),
|
||||
)
|
||||
}.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,
|
||||
)
|
||||
}.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,
|
||||
@@ -691,6 +936,11 @@ class MessagesFlowNode(
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
mentionSpanTheme.updateStyles()
|
||||
|
||||
if (displayVulkanNotSupportedError) {
|
||||
RenderingMapsNotSupportedDialog { displayVulkanNotSupportedError = false }
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalMentionSpanUpdater provides mentionSpanUpdater
|
||||
) {
|
||||
@@ -698,3 +948,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
|
||||
}
|
||||
}
|
||||
|
||||
+22
-1
@@ -66,6 +66,7 @@ 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.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
|
||||
@@ -107,7 +108,7 @@ class MessagesNode(
|
||||
private val timelineController = TimelineController(room, room.liveTimeline)
|
||||
private val presenter = presenterFactory.create(
|
||||
navigator = this,
|
||||
composerPresenter = messageComposerPresenterFactory.create(timelineController, this, isInThread = false),
|
||||
composerPresenter = messageComposerPresenterFactory.create(timelineController, this, threadRoot = null),
|
||||
timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this),
|
||||
actionListPresenter = actionListPresenterFactory.create(
|
||||
postProcessor = TimelineItemActionPostProcessor.Default,
|
||||
@@ -118,6 +119,7 @@ class MessagesNode(
|
||||
|
||||
interface Callback : Plugin {
|
||||
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)
|
||||
@@ -136,6 +138,8 @@ class MessagesNode(
|
||||
fun navigateToDeveloperSettings()
|
||||
|
||||
fun navigateToThreadsList()
|
||||
|
||||
fun navigateToAvatarPreview(username: String, avatarUrl: String)
|
||||
}
|
||||
|
||||
override fun onBuilt() {
|
||||
@@ -286,6 +290,18 @@ class MessagesNode(
|
||||
}
|
||||
}
|
||||
},
|
||||
onGalleryEventItemClick = { isLive, event, index ->
|
||||
if (isLive) {
|
||||
callback.handleGalleryItemClick(timelineController.mainTimelineMode(), event, index, canUseOverlay)
|
||||
} else {
|
||||
val detachedTimelineMode = timelineController.detachedTimelineMode()
|
||||
if (detachedTimelineMode != null) {
|
||||
callback.handleGalleryItemClick(detachedTimelineMode, event, index, canUseOverlay)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
},
|
||||
onUserDataClick = callback::navigateToRoomMemberDetails,
|
||||
onLinkClick = { url, customTab ->
|
||||
onLinkClick(
|
||||
@@ -319,6 +335,11 @@ class MessagesNode(
|
||||
else -> state.roomMemberModerationState.eventSink(RoomMemberModerationEvents.ProcessAction(action, target))
|
||||
}
|
||||
},
|
||||
onAvatarClick = { user ->
|
||||
user.avatarUrl?.let { url ->
|
||||
callback.navigateToAvatarPreview(user.getBestName(), url)
|
||||
}
|
||||
},
|
||||
modifier = Modifier,
|
||||
)
|
||||
|
||||
|
||||
+3
-3
@@ -47,10 +47,10 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.captionOrNull
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter
|
||||
import io.element.android.features.roomcall.api.RoomCallState
|
||||
@@ -533,7 +533,7 @@ class MessagesPresenter(
|
||||
) {
|
||||
val composerMode = MessageComposerMode.EditCaption(
|
||||
eventOrTransactionId = targetEvent.eventOrTransactionId,
|
||||
content = (targetEvent.content as? TimelineItemEventContentWithAttachment)?.caption.orEmpty(),
|
||||
content = targetEvent.content.captionOrNull().orEmpty(),
|
||||
)
|
||||
composerState.eventSink(
|
||||
MessageComposerEvent.SetMode(composerMode)
|
||||
@@ -606,7 +606,7 @@ class MessagesPresenter(
|
||||
}
|
||||
|
||||
private fun handleCopyCaption(event: TimelineItem.Event) {
|
||||
val content = (event.content as? TimelineItemEventContentWithAttachment)?.caption ?: return
|
||||
val content = event.content.captionOrNull() ?: return
|
||||
clipboardHelper.copyPlainText(content)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
|
||||
|
||||
+43
-25
@@ -136,6 +136,7 @@ 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,
|
||||
@@ -207,15 +208,15 @@ fun MessagesView(
|
||||
val expandableState = rememberExpandableBottomSheetLayoutState()
|
||||
ExpandableBottomSheetLayout(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.systemBarsPadding()
|
||||
.onSizeChanged { size ->
|
||||
// Let the composer takes at max half of the available height.
|
||||
// The value will be different if the soft keyboard is displayed
|
||||
// or not.
|
||||
maxComposerHeightPx = (size.height * 0.5f).toInt()
|
||||
},
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.systemBarsPadding()
|
||||
.onSizeChanged { size ->
|
||||
// Let the composer takes at max half of the available height.
|
||||
// The value will be different if the soft keyboard is displayed
|
||||
// or not.
|
||||
maxComposerHeightPx = (size.height * 0.5f).toInt()
|
||||
},
|
||||
content = {
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets.statusBars,
|
||||
@@ -252,12 +253,22 @@ fun MessagesView(
|
||||
content = { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
) {
|
||||
MessagesViewContent(
|
||||
state = state,
|
||||
onContentClick = ::onContentClick,
|
||||
onGalleryItemClick = { event, index ->
|
||||
val hideKeyboard = onGalleryEventItemClick(
|
||||
state.timelineState.isLive,
|
||||
event,
|
||||
index,
|
||||
)
|
||||
if (hideKeyboard) {
|
||||
localView.hideKeyboard()
|
||||
}
|
||||
},
|
||||
onMessageLongClick = ::onMessageLongClick,
|
||||
onUserDataClick = {
|
||||
hidingKeyboard {
|
||||
@@ -290,10 +301,10 @@ fun MessagesView(
|
||||
|
||||
SuggestionsPickerView(
|
||||
modifier = Modifier
|
||||
.shadow(10.dp)
|
||||
.background(ElementTheme.colors.bgCanvasDefault)
|
||||
.align(Alignment.BottomStart)
|
||||
.heightIn(max = 230.dp),
|
||||
.shadow(10.dp)
|
||||
.background(ElementTheme.colors.bgCanvasDefault)
|
||||
.align(Alignment.BottomStart)
|
||||
.heightIn(max = 230.dp),
|
||||
roomId = state.roomId,
|
||||
roomName = state.roomName,
|
||||
roomAvatarData = state.roomAvatar,
|
||||
@@ -459,6 +470,7 @@ private fun MessagesViewContent(
|
||||
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
|
||||
onReadReceiptClick: (TimelineItem.Event) -> Unit,
|
||||
onMessageLongClick: (TimelineItem.Event) -> Unit,
|
||||
onGalleryItemClick: ((TimelineItem.Event, Int) -> Unit),
|
||||
onSendLocationClick: () -> Unit,
|
||||
onCreatePollClick: () -> Unit,
|
||||
onViewAllPinnedMessagesClick: () -> Unit,
|
||||
@@ -469,9 +481,9 @@ private fun MessagesViewContent(
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
.imePadding(),
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
.imePadding(),
|
||||
) {
|
||||
AttachmentsBottomSheet(
|
||||
state = state.composerState,
|
||||
@@ -502,7 +514,9 @@ private fun MessagesViewContent(
|
||||
pinnedMessagesCount = (state.pinnedMessagesBannerState as? PinnedMessagesBannerState.Visible)?.pinnedMessagesCount() ?: 0,
|
||||
)
|
||||
val density = LocalDensity.current
|
||||
var pinnedBannerHeightDp by remember { mutableStateOf(0.dp) }
|
||||
// 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,
|
||||
@@ -510,6 +524,7 @@ private fun MessagesViewContent(
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClick = { link -> onLinkClick(link, false) },
|
||||
onContentClick = onContentClick,
|
||||
onGalleryItemClick = onGalleryItemClick,
|
||||
onMessageLongClick = onMessageLongClick,
|
||||
onSwipeToReply = onSwipeToReply,
|
||||
onReactionClick = onReactionClick,
|
||||
@@ -518,14 +533,15 @@ private fun MessagesViewContent(
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
||||
nestedScrollConnection = scrollBehavior.nestedScrollConnection,
|
||||
floatingDateTopOffset = pinnedBannerHeightDp,
|
||||
floatingDateTopOffset = topBannersHeightDp,
|
||||
)
|
||||
|
||||
if (state.timelineState.timelineMode !is Timeline.Mode.Thread) {
|
||||
Column {
|
||||
Column(
|
||||
modifier = Modifier.onSizeChanged { topBannersHeightDp = with(density) { it.height.toDp() } },
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
|
||||
modifier = Modifier.onSizeChanged { pinnedBannerHeightDp = with(density) { it.height.toDp() } },
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
@@ -598,9 +614,9 @@ private fun MessagesViewComposerBottomSheetContents(
|
||||
private fun CantSendMessageBanner() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(ElementTheme.colors.bgSubtleSecondary)
|
||||
.padding(16.dp),
|
||||
.fillMaxWidth()
|
||||
.background(ElementTheme.colors.bgSubtleSecondary)
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
@@ -637,6 +653,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
|
||||
onBackClick = {},
|
||||
onRoomDetailsClick = {},
|
||||
onEventContentClick = { _, _ -> false },
|
||||
onGalleryEventItemClick = { _, _, _ -> false },
|
||||
onUserDataClick = {},
|
||||
onLinkClick = { _, _ -> },
|
||||
onSendLocationClick = {},
|
||||
@@ -692,6 +709,7 @@ internal fun MessagesViewA11yPreview() = ElementPreview {
|
||||
onBackClick = {},
|
||||
onRoomDetailsClick = {},
|
||||
onEventContentClick = { _, _ -> false },
|
||||
onGalleryEventItemClick = { _, _, _ -> false },
|
||||
onUserDataClick = {},
|
||||
onLinkClick = { _, _ -> },
|
||||
onSendLocationClick = {},
|
||||
|
||||
+9
-3
@@ -27,8 +27,10 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
||||
@@ -37,6 +39,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||
import io.element.android.features.messages.impl.timeline.model.event.canBeCopied
|
||||
import io.element.android.features.messages.impl.timeline.model.event.canBeForwarded
|
||||
import io.element.android.features.messages.impl.timeline.model.event.canReact
|
||||
import io.element.android.features.messages.impl.timeline.model.event.captionOrNull
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
||||
@@ -198,9 +201,12 @@ class DefaultActionListPresenter(
|
||||
add(TimelineItemAction.Forward)
|
||||
}
|
||||
if (timelineItem.isEditable && usersEventPermissions.canSendMessage) {
|
||||
if (timelineItem.content is TimelineItemEventContentWithAttachment) {
|
||||
if (timelineItem.content is TimelineItemEventContentWithAttachment ||
|
||||
timelineItem.content is TimelineItemGalleryContent ||
|
||||
timelineItem.content is TimelineItemAttachmentsContent) {
|
||||
// Caption
|
||||
if (timelineItem.content.caption == null) {
|
||||
val caption = timelineItem.content.captionOrNull()
|
||||
if (caption == null) {
|
||||
add(TimelineItemAction.AddCaption)
|
||||
} else {
|
||||
add(TimelineItemAction.EditCaption)
|
||||
@@ -225,7 +231,7 @@ class DefaultActionListPresenter(
|
||||
}
|
||||
if (timelineItem.content.canBeCopied()) {
|
||||
add(TimelineItemAction.CopyText)
|
||||
} else if ((timelineItem.content as? TimelineItemEventContentWithAttachment)?.caption.isNullOrBlank().not()) {
|
||||
} else if (timelineItem.content.captionOrNull().isNullOrBlank().not()) {
|
||||
add(TimelineItemAction.CopyCaption)
|
||||
}
|
||||
if (timelineItem.isRemote) {
|
||||
|
||||
+17
-4
@@ -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
|
||||
|
||||
@@ -249,7 +254,7 @@ private fun ActionListViewContent(
|
||||
leadingContent = ListItemContent.Icon(IconSource.Resource(action.icon)),
|
||||
style = when {
|
||||
action.destructive -> ListItemStyle.Destructive
|
||||
else -> ListItemStyle.Primary
|
||||
else -> ListItemStyle.Default
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -299,6 +304,12 @@ private fun MessageSummary(
|
||||
is TimelineItemImageContent -> {
|
||||
content = { ContentForBody(event.content.bestDescription) }
|
||||
}
|
||||
is TimelineItemGalleryContent -> {
|
||||
content = { ContentForBody(event.content.body) }
|
||||
}
|
||||
is TimelineItemAttachmentsContent -> {
|
||||
content = { ContentForBody(event.content.body) }
|
||||
}
|
||||
is TimelineItemStickerContent -> {
|
||||
content = { ContentForBody(event.content.bestDescription) }
|
||||
}
|
||||
@@ -401,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(
|
||||
|
||||
+3
@@ -17,8 +17,11 @@ sealed interface 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,
|
||||
|
||||
+244
-174
@@ -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
|
||||
@@ -48,10 +50,11 @@ import io.element.android.libraries.mediaupload.api.allFiles
|
||||
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
@@ -59,7 +62,7 @@ import java.io.File
|
||||
|
||||
@AssistedInject
|
||||
class AttachmentsPreviewPresenter(
|
||||
@Assisted private val attachment: Attachment,
|
||||
@Assisted private val attachments: ImmutableList<Attachment>,
|
||||
@Assisted private val onDoneListener: OnDoneListener,
|
||||
@Assisted private val timelineMode: Timeline.Mode,
|
||||
@Assisted private val inReplyToEventId: EventId?,
|
||||
@@ -76,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
|
||||
@@ -92,14 +100,11 @@ class AttachmentsPreviewPresenter(
|
||||
val sendActionState = remember {
|
||||
mutableStateOf<SendActionState>(SendActionState.Idle)
|
||||
}
|
||||
val originalLocalMedia = remember { (attachment as Attachment.Media).localMedia }
|
||||
var currentAttachment by remember { mutableStateOf(attachment) }
|
||||
var canEditImage by remember { mutableStateOf(originalLocalMedia.info.canEditImage()) }
|
||||
var canEditImage by remember { mutableStateOf(false) }
|
||||
var imageEditorState by remember { mutableStateOf<AttachmentImageEditorState?>(null) }
|
||||
var appliedImageEdits by remember { mutableStateOf(AttachmentImageEdits()) }
|
||||
var isApplyingImageEdits by remember { mutableStateOf(false) }
|
||||
var displayImageEditError by remember { mutableStateOf(false) }
|
||||
var editedTempFile by remember { mutableStateOf<File?>(null) }
|
||||
var editedTempFiles by remember { mutableStateOf<Map<Int, File>>(emptyMap()) }
|
||||
|
||||
val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false)
|
||||
val textEditorState by rememberUpdatedState(
|
||||
@@ -108,76 +113,115 @@ class AttachmentsPreviewPresenter(
|
||||
|
||||
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
|
||||
|
||||
var preprocessMediaJob by remember { mutableStateOf<Job?>(null) }
|
||||
var currentIndex by remember { mutableIntStateOf(0) }
|
||||
|
||||
val mediaAttachment = currentAttachment as Attachment.Media
|
||||
val mediaOptimizationSelectorPresenter = remember {
|
||||
mediaOptimizationSelectorPresenterFactory.create(
|
||||
localMedia = mediaAttachment.localMedia,
|
||||
sendAsFile = mediaAttachment.sendAsFile,
|
||||
var attachmentsAndEdits by remember {
|
||||
mutableStateOf(
|
||||
attachments.map {
|
||||
AttachmentAndEdits(it, AttachmentImageEdits())
|
||||
}
|
||||
)
|
||||
}
|
||||
val mediaOptimizationSelectorState by rememberUpdatedState(mediaOptimizationSelectorPresenter.present())
|
||||
|
||||
val editedAttachments by remember {
|
||||
derivedStateOf {
|
||||
attachmentsAndEdits.map { it.attachment }.toImmutableList()
|
||||
}
|
||||
}
|
||||
|
||||
var preprocessMediaJob by remember { mutableStateOf<Job?>(null) }
|
||||
|
||||
val mediaOptimizationSelectorPresenters = remember {
|
||||
attachments
|
||||
.filterIsInstance<Attachment.Media>()
|
||||
.mapIndexed { index, attachment ->
|
||||
mediaOptimizationSelectorPresenterFactory.create(
|
||||
index = index,
|
||||
localMedia = attachment.localMedia,
|
||||
sendAsFile = attachment.sendAsFile,
|
||||
)
|
||||
}
|
||||
}
|
||||
val mediaOptimizationSelectorStates by rememberUpdatedState(
|
||||
mediaOptimizationSelectorPresenters.map {
|
||||
it.present()
|
||||
}.toImmutableList()
|
||||
)
|
||||
|
||||
val observableSendState = snapshotFlow { sendActionState.value }
|
||||
|
||||
var displayFileTooLargeError by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(
|
||||
mediaOptimizationSelectorState.displayMediaSelectorViews,
|
||||
mediaOptimizationSelectorState.videoSizeEstimations,
|
||||
currentAttachment,
|
||||
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.
|
||||
@Suppress("ComplexCondition")
|
||||
if (mediaOptimizationSelectorState.displayMediaSelectorViews == false &&
|
||||
preprocessMediaJob == null &&
|
||||
imageEditorState == null &&
|
||||
!isApplyingImageEdits) {
|
||||
if (mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo() && mediaOptimizationSelectorState.videoSizeEstimations.dataOrNull() == null) {
|
||||
Timber.d("Waiting for video size estimations to be able to select the best video compression preset before pre-processing the media")
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val config = getAutoPreprocessMediaOptimizationConfig(
|
||||
mediaAttachment = mediaAttachment,
|
||||
val configs = mediaOptimizationSelectorStates.mapIndexed { index, mediaOptimizationSelectorState ->
|
||||
getAutoPreprocessMediaOptimizationConfig(
|
||||
mediaAttachment = editedAttachments[index] as Attachment.Media,
|
||||
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
|
||||
) ?: return@LaunchedEffect
|
||||
preprocessMediaJob = coroutineScope.preProcessAttachment(
|
||||
attachment = currentAttachment,
|
||||
mediaOptimizationConfig = config,
|
||||
)
|
||||
}
|
||||
preprocessMediaJob?.cancel()
|
||||
preprocessMediaJob = coroutineScope.launch(dispatchers.io) {
|
||||
preProcessAttachments(
|
||||
attachments = editedAttachments,
|
||||
mediaOptimizationConfigs = configs,
|
||||
displayProgress = false,
|
||||
sendActionState = sendActionState,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(originalLocalMedia) {
|
||||
canEditImage = originalLocalMedia.info.canEditImage() || attachmentImageEditor.canEdit(originalLocalMedia)
|
||||
LaunchedEffect(currentIndex) {
|
||||
val currentMedia = (attachments.getOrNull(currentIndex) as? Attachment.Media)?.localMedia
|
||||
if (currentMedia != null) {
|
||||
canEditImage = currentMedia.info.canEditImage() || attachmentImageEditor.canEdit(currentMedia)
|
||||
}
|
||||
}
|
||||
|
||||
val maxUploadSize = mediaOptimizationSelectorState.maxUploadSize.dataOrNull()
|
||||
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.isImageAttachment()
|
||||
val isVideoFile = mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo()
|
||||
if (maxUploadSize != null && !(isImageFile || isVideoFile)) {
|
||||
if (maxUploadSize != null) {
|
||||
// If file size is not known, we're permissive and allow sending. The SDK will cancel the upload if needed.
|
||||
val fileSize = mediaAttachment.localMedia.info.fileSize ?: 0L
|
||||
if (maxUploadSize < fileSize) {
|
||||
displayFileTooLargeError = true
|
||||
displayFileTooLargeError = attachments.any { attachment ->
|
||||
when (attachment) {
|
||||
is Attachment.Media -> {
|
||||
val isImageFile = attachment.localMedia.info.isImageAttachment()
|
||||
val isVideoFile = attachment.localMedia.info.mimeType.isMimeTypeVideo()
|
||||
if (isImageFile || isVideoFile) {
|
||||
false
|
||||
} else {
|
||||
val fileSize = attachment.localMedia.info.fileSize ?: 0L
|
||||
maxUploadSize < fileSize
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,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 = currentAttachment,
|
||||
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
|
||||
@@ -206,35 +254,27 @@ class AttachmentsPreviewPresenter(
|
||||
}
|
||||
|
||||
// Wait until the media is ready to be uploaded
|
||||
val mediaUploadInfo = observableSendState.firstInstanceOf<SendActionState.Sending.ReadyToUpload>().mediaInfo
|
||||
val allMediaUploadInfos = observableSendState.firstInstanceOf<SendActionState.Sending.ReadyToUpload>().mediaInfos
|
||||
|
||||
// Pre-processing is done, send the attachment
|
||||
val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder)
|
||||
.takeIf { it.isNotEmpty() }
|
||||
|
||||
val editedTempFileToDelete = editedTempFile
|
||||
editedTempFile = null
|
||||
|
||||
// If we're supposed to send the media as a background job, we can dismiss this screen already
|
||||
if (coroutineContext.isActive) {
|
||||
onDoneListener()
|
||||
}
|
||||
val editedTempFilesToDelete = editedTempFiles
|
||||
editedTempFiles = emptyMap()
|
||||
|
||||
// Send the media using the session coroutine scope so it doesn't matter if this screen or the chat one are closed
|
||||
sessionCoroutineScope.launch(dispatchers.io) {
|
||||
try {
|
||||
sendPreProcessedMedia(
|
||||
mediaUploadInfo = mediaUploadInfo,
|
||||
caption = caption,
|
||||
sendActionState = sendActionState,
|
||||
dismissAfterSend = false,
|
||||
inReplyToEventId = inReplyToEventId,
|
||||
)
|
||||
} finally {
|
||||
editedTempFileToDelete?.safeDelete()
|
||||
// Clean up the pre-processed media after it's been sent
|
||||
mediaSender.cleanUp()
|
||||
}
|
||||
sendMedia(
|
||||
mediaUploadInfos = allMediaUploadInfos,
|
||||
caption = caption,
|
||||
sendActionState = sendActionState,
|
||||
inReplyToEventId = inReplyToEventId,
|
||||
)
|
||||
|
||||
// Clean up the pre-processed media after it's been sent
|
||||
mediaSender.cleanUp()
|
||||
editedTempFilesToDelete.values.forEach { it.safeDelete() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -251,7 +291,11 @@ class AttachmentsPreviewPresenter(
|
||||
ongoingSendAttachmentJob.value?.cancel()
|
||||
|
||||
// Dismiss the screen
|
||||
dismiss(sendActionState, editedTempFile)
|
||||
dismiss(
|
||||
attachments = editedAttachments,
|
||||
sendActionState = sendActionState,
|
||||
editedTempFiles = editedTempFiles,
|
||||
)
|
||||
}
|
||||
AttachmentsPreviewEvent.CancelAndClearSendState -> {
|
||||
// Cancel media sending
|
||||
@@ -260,22 +304,23 @@ class AttachmentsPreviewPresenter(
|
||||
ongoingSendAttachmentJob.value = null
|
||||
}
|
||||
|
||||
val mediaUploadInfo = sendActionState.value.mediaUploadInfo()
|
||||
sendActionState.value = if (mediaUploadInfo != null) {
|
||||
SendActionState.Sending.ReadyToUpload(mediaUploadInfo)
|
||||
val mediaUploadInfoList = sendActionState.value.mediaUploadInfoList()
|
||||
sendActionState.value = if (mediaUploadInfoList != null) {
|
||||
SendActionState.Sending.ReadyToUpload(mediaUploadInfoList)
|
||||
} else {
|
||||
SendActionState.Idle
|
||||
}
|
||||
}
|
||||
AttachmentsPreviewEvent.OpenImageEditor -> {
|
||||
val resolvedCanEditImage = canEditImage || originalLocalMedia.info.canEditImage()
|
||||
val currentLocalMedia = (attachments.getOrNull(currentIndex) as? Attachment.Media)?.localMedia ?: return
|
||||
val resolvedCanEditImage = canEditImage || currentLocalMedia.info.canEditImage()
|
||||
if (resolvedCanEditImage) {
|
||||
preprocessMediaJob?.cancel()
|
||||
preprocessMediaJob = null
|
||||
resetPreparedMedia(sendActionState)
|
||||
imageEditorState = AttachmentImageEditorState(
|
||||
localMedia = originalLocalMedia,
|
||||
edits = appliedImageEdits,
|
||||
localMedia = currentLocalMedia,
|
||||
edits = attachmentsAndEdits.get(currentIndex).edits,
|
||||
previewDebug = false,
|
||||
)
|
||||
}
|
||||
@@ -295,6 +340,18 @@ class AttachmentsPreviewPresenter(
|
||||
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()
|
||||
@@ -303,10 +360,15 @@ class AttachmentsPreviewPresenter(
|
||||
AttachmentsPreviewEvent.ApplyImageEdits -> {
|
||||
val pendingState = imageEditorState ?: return
|
||||
if (!pendingState.edits.hasChanges) {
|
||||
editedTempFile?.safeDelete()
|
||||
editedTempFile = null
|
||||
appliedImageEdits = pendingState.edits
|
||||
currentAttachment = Attachment.Media(originalLocalMedia)
|
||||
editedTempFiles[currentIndex]?.safeDelete()
|
||||
editedTempFiles = editedTempFiles - currentIndex
|
||||
val currentAttachment = attachmentsAndEdits[currentIndex].attachment
|
||||
attachmentsAndEdits = attachmentsAndEdits.toMutableList().also {
|
||||
it[currentIndex] = AttachmentAndEdits(
|
||||
currentAttachment,
|
||||
pendingState.edits,
|
||||
)
|
||||
}.toImmutableList()
|
||||
imageEditorState = null
|
||||
resetPreparedMedia(sendActionState)
|
||||
return
|
||||
@@ -316,16 +378,21 @@ class AttachmentsPreviewPresenter(
|
||||
coroutineScope.launch {
|
||||
val result = withContext(dispatchers.io) {
|
||||
attachmentImageEditor.exportEdits(
|
||||
localMedia = originalLocalMedia,
|
||||
localMedia = pendingState.localMedia,
|
||||
edits = pendingState.edits,
|
||||
)
|
||||
}
|
||||
result.fold(
|
||||
onSuccess = { editedMedia ->
|
||||
editedTempFile?.safeDelete()
|
||||
editedTempFile = editedMedia.file
|
||||
appliedImageEdits = pendingState.edits
|
||||
currentAttachment = Attachment.Media(editedMedia.localMedia)
|
||||
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)
|
||||
},
|
||||
@@ -340,19 +407,23 @@ class AttachmentsPreviewPresenter(
|
||||
AttachmentsPreviewEvent.ClearImageEditError -> {
|
||||
displayImageEditError = false
|
||||
}
|
||||
is AttachmentsPreviewEvent.SetCurrentCarouselIndex -> {
|
||||
currentIndex = event.index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return AttachmentsPreviewState(
|
||||
attachment = currentAttachment,
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -360,7 +431,7 @@ class AttachmentsPreviewPresenter(
|
||||
private suspend fun getAutoPreprocessMediaOptimizationConfig(
|
||||
mediaAttachment: Attachment.Media,
|
||||
mediaOptimizationSelectorState: MediaOptimizationSelectorState,
|
||||
): MediaOptimizationConfig? {
|
||||
): 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)
|
||||
@@ -374,71 +445,66 @@ class AttachmentsPreviewPresenter(
|
||||
videoCompressionPreset = videoCompressionPreset,
|
||||
)
|
||||
} else {
|
||||
// Otherwise, we just rely on the user preferences for media optimization
|
||||
mediaOptimizationConfigProvider.get()
|
||||
MediaOptimizationConfig(
|
||||
compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled
|
||||
?: mediaOptimizationConfigProvider.get().compressImages,
|
||||
videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset
|
||||
?: mediaOptimizationConfigProvider.get().videoCompressionPreset,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.preProcessAttachment(
|
||||
attachment: Attachment,
|
||||
mediaOptimizationConfig: MediaOptimizationConfig,
|
||||
displayProgress: Boolean,
|
||||
sendActionState: MutableState<SendActionState>,
|
||||
) = launch(dispatchers.io) {
|
||||
when (attachment) {
|
||||
is Attachment.Media -> {
|
||||
preProcessMedia(
|
||||
mediaAttachment = attachment,
|
||||
mediaOptimizationConfig = mediaOptimizationConfig,
|
||||
displayProgress = displayProgress,
|
||||
sendActionState = sendActionState,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun preProcessMedia(
|
||||
mediaAttachment: Attachment.Media,
|
||||
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(
|
||||
sendActionState: MutableState<SendActionState>,
|
||||
editedTempFile: File?,
|
||||
) {
|
||||
// Delete the temporary file
|
||||
when (attachment) {
|
||||
is Attachment.Media -> {
|
||||
temporaryUriDeleter.delete(attachment.localMedia.uri)
|
||||
sendActionState.value.mediaUploadInfo()?.let { data ->
|
||||
cleanUp(data)
|
||||
val mediaUploadInfos = mutableListOf<MediaUploadInfo>()
|
||||
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
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
editedTempFile?.safeDelete()
|
||||
// Reset the sendActionState to ensure that dialog is closed before the screen
|
||||
sendActionState.value = SendActionState.Sending.ReadyToUpload(mediaUploadInfos)
|
||||
}
|
||||
|
||||
private fun dismiss(
|
||||
attachments: List<Attachment>,
|
||||
sendActionState: MutableState<SendActionState>,
|
||||
editedTempFiles: Map<Int, File> = emptyMap(),
|
||||
) {
|
||||
for (attachment in attachments) {
|
||||
when (attachment) {
|
||||
is Attachment.Media -> {
|
||||
temporaryUriDeleter.delete(attachment.localMedia.uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
val uploadInfos = (sendActionState.value as? SendActionState.Sending.ReadyToUpload)?.mediaInfos
|
||||
uploadInfos?.forEach { cleanUp(it) }
|
||||
editedTempFiles.values.forEach { it.safeDelete() }
|
||||
sendActionState.value = SendActionState.Done
|
||||
onDoneListener()
|
||||
}
|
||||
@@ -452,41 +518,45 @@ class AttachmentsPreviewPresenter(
|
||||
}
|
||||
|
||||
private fun resetPreparedMedia(sendActionState: MutableState<SendActionState>) {
|
||||
sendActionState.value.mediaUploadInfo()?.let(::cleanUp)
|
||||
sendActionState.value.mediaUploadInfoList()?.forEach(::cleanUp)
|
||||
mediaSender.cleanUp()
|
||||
sendActionState.value = SendActionState.Idle
|
||||
}
|
||||
|
||||
private suspend fun sendPreProcessedMedia(
|
||||
mediaUploadInfo: MediaUploadInfo,
|
||||
private suspend fun 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)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
+14
-9
@@ -14,9 +14,10 @@ import io.element.android.features.messages.impl.attachments.preview.imageeditor
|
||||
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
|
||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class AttachmentsPreviewState(
|
||||
val attachment: Attachment,
|
||||
val attachments: ImmutableList<Attachment>,
|
||||
val imageEditorState: AttachmentImageEditorState?,
|
||||
val canEditImage: Boolean,
|
||||
val isApplyingImageEdits: Boolean,
|
||||
@@ -25,8 +26,12 @@ data class AttachmentsPreviewState(
|
||||
val textEditorState: TextEditorState,
|
||||
val mediaOptimizationSelectorState: MediaOptimizationSelectorState,
|
||||
val displayFileTooLargeError: Boolean,
|
||||
val currentIndex: Int,
|
||||
val eventSink: (AttachmentsPreviewEvent) -> Unit,
|
||||
)
|
||||
) {
|
||||
val isGallery: Boolean get() = attachments.size > 1
|
||||
val totalCount: Int get() = attachments.size
|
||||
}
|
||||
|
||||
@Immutable
|
||||
sealed interface SendActionState {
|
||||
@@ -35,17 +40,17 @@ sealed interface SendActionState {
|
||||
@Immutable
|
||||
sealed interface Sending : SendActionState {
|
||||
data class Processing(val displayProgress: Boolean) : Sending
|
||||
data class ReadyToUpload(val mediaInfo: MediaUploadInfo) : Sending
|
||||
data class Uploading(val mediaUploadInfo: MediaUploadInfo) : Sending
|
||||
data class ReadyToUpload(val mediaInfos: List<MediaUploadInfo>) : Sending
|
||||
data class Uploading(val mediaInfos: List<MediaUploadInfo>) : Sending
|
||||
}
|
||||
|
||||
data class Failure(val error: Throwable, val mediaUploadInfo: MediaUploadInfo?) : SendActionState
|
||||
data class Failure(val error: Throwable, val mediaInfos: List<MediaUploadInfo>) : SendActionState
|
||||
data object Done : SendActionState
|
||||
|
||||
fun mediaUploadInfo(): MediaUploadInfo? = when (this) {
|
||||
is Sending.ReadyToUpload -> mediaInfo
|
||||
is Sending.Uploading -> mediaUploadInfo
|
||||
is Failure -> mediaUploadInfo
|
||||
fun mediaUploadInfoList(): List<MediaUploadInfo>? = when (this) {
|
||||
is Sending.ReadyToUpload -> mediaInfos
|
||||
is Sending.Uploading -> mediaInfos
|
||||
is Failure -> mediaInfos
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
+40
-5
@@ -41,9 +41,9 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider<Attachment
|
||||
)
|
||||
),
|
||||
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Processing(displayProgress = true)),
|
||||
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.ReadyToUpload(aMediaUploadInfo())),
|
||||
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(aMediaUploadInfo())),
|
||||
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"), aMediaUploadInfo())),
|
||||
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.ReadyToUpload(listOf(aMediaUploadInfo()))),
|
||||
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(listOf(aMediaUploadInfo()))),
|
||||
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"), listOf(aMediaUploadInfo()))),
|
||||
anAttachmentsPreviewState(
|
||||
imageEditorState = anAttachmentImageEditorState(),
|
||||
),
|
||||
@@ -72,9 +72,12 @@ fun anAttachmentsPreviewState(
|
||||
imageEditorState: AttachmentImageEditorState? = null,
|
||||
mediaOptimizationSelectorState: MediaOptimizationSelectorState = aMediaOptimisationSelectorState(),
|
||||
displayFileTooLargeError: Boolean = false,
|
||||
currentIndex: Int = 0,
|
||||
) = AttachmentsPreviewState(
|
||||
attachment = Attachment.Media(
|
||||
localMedia = LocalMedia("file://path".toUri(), mediaInfo),
|
||||
attachments = persistentListOf(
|
||||
Attachment.Media(
|
||||
localMedia = LocalMedia("file://path".toUri(), mediaInfo),
|
||||
),
|
||||
),
|
||||
imageEditorState = imageEditorState,
|
||||
canEditImage = true,
|
||||
@@ -84,6 +87,37 @@ fun anAttachmentsPreviewState(
|
||||
textEditorState = textEditorState,
|
||||
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
|
||||
displayFileTooLargeError = displayFileTooLargeError,
|
||||
currentIndex = currentIndex,
|
||||
eventSink = {}
|
||||
)
|
||||
|
||||
fun anAttachmentsPreviewGalleryState(
|
||||
mediaInfo: MediaInfo = anImageMediaInfo(),
|
||||
textEditorState: TextEditorState = aTextEditorStateMarkdown(),
|
||||
sendActionState: SendActionState = SendActionState.Idle,
|
||||
mediaOptimizationSelectorState: MediaOptimizationSelectorState = aMediaOptimisationSelectorState(),
|
||||
currentIndex: Int = 0,
|
||||
) = AttachmentsPreviewState(
|
||||
attachments = persistentListOf(
|
||||
Attachment.Media(
|
||||
localMedia = LocalMedia("file://path1".toUri(), mediaInfo),
|
||||
),
|
||||
Attachment.Media(
|
||||
localMedia = LocalMedia("file://path2".toUri(), mediaInfo),
|
||||
),
|
||||
Attachment.Media(
|
||||
localMedia = LocalMedia("file://path3".toUri(), mediaInfo),
|
||||
),
|
||||
),
|
||||
imageEditorState = null,
|
||||
canEditImage = false,
|
||||
isApplyingImageEdits = false,
|
||||
displayImageEditError = false,
|
||||
sendActionState = sendActionState,
|
||||
textEditorState = textEditorState,
|
||||
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
|
||||
displayFileTooLargeError = false,
|
||||
currentIndex = currentIndex,
|
||||
eventSink = {}
|
||||
)
|
||||
|
||||
@@ -112,6 +146,7 @@ fun aMediaOptimisationSelectorState(
|
||||
displayMediaSelectorViews: Boolean = true,
|
||||
displayVideoPresetSelectorDialog: Boolean = false,
|
||||
) = MediaOptimizationSelectorState(
|
||||
index = 0,
|
||||
maxUploadSize = AsyncData.Success(maxUploadSize),
|
||||
videoSizeEstimations = videoSizeEstimations,
|
||||
isImageOptimizationEnabled = isImageOptimizationEnabled,
|
||||
|
||||
+124
-24
@@ -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,15 +25,19 @@ 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
|
||||
@@ -37,6 +45,7 @@ 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.messages.impl.R
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
|
||||
@@ -57,14 +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.TextButton
|
||||
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
|
||||
@@ -75,6 +85,9 @@ 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
|
||||
@@ -136,6 +149,8 @@ fun AttachmentsPreviewView(
|
||||
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,
|
||||
@@ -161,10 +176,14 @@ fun AttachmentsPreviewView(
|
||||
},
|
||||
actions = {
|
||||
if (state.canEditImage && canShowEditAction) {
|
||||
TextButton(
|
||||
stringResource(CommonStrings.action_edit),
|
||||
onClick = ::postOpenImageEditor
|
||||
)
|
||||
IconButton(
|
||||
onClick = ::postOpenImageEditor,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Crop(),
|
||||
contentDescription = stringResource(CommonStrings.action_edit),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -244,6 +263,7 @@ private fun AttachmentSendStateView(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun AttachmentPreviewContent(
|
||||
state: AttachmentsPreviewState,
|
||||
@@ -259,15 +279,74 @@ 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 mediaInfo = (state.attachment as? Attachment.Media)?.localMedia?.info
|
||||
val mediaInfo = (state.attachments[state.currentIndex] as? Attachment.Media)?.localMedia?.info
|
||||
if (mediaInfo?.isImageAttachment() == true) {
|
||||
ImageOptimizationSelector(state.mediaOptimizationSelectorState)
|
||||
} else if (mediaInfo?.mimeType?.isMimeTypeVideo() == true) {
|
||||
@@ -477,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(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -529,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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+24
-13
@@ -87,30 +87,30 @@ class DefaultAttachmentImageEditor(
|
||||
decodedBitmap.recycle()
|
||||
}
|
||||
|
||||
val rotatedBitmap = normalizedBitmap.rotateQuarterTurns(edits.rotationQuarterTurns)
|
||||
if (rotatedBitmap !== normalizedBitmap) {
|
||||
val transformedBitmap = normalizedBitmap.applyEdits(edits)
|
||||
if (transformedBitmap !== normalizedBitmap) {
|
||||
normalizedBitmap.recycle()
|
||||
}
|
||||
|
||||
val cropRect = edits.cropRect.toPixelRect(
|
||||
imageWidth = rotatedBitmap.width,
|
||||
imageHeight = rotatedBitmap.height,
|
||||
imageWidth = transformedBitmap.width,
|
||||
imageHeight = transformedBitmap.height,
|
||||
)
|
||||
val isCropUnchanged = cropRect.left == 0 && cropRect.top == 0 &&
|
||||
cropRect.width() == rotatedBitmap.width && cropRect.height() == rotatedBitmap.height
|
||||
cropRect.width() == transformedBitmap.width && cropRect.height() == transformedBitmap.height
|
||||
val croppedBitmap = if (isCropUnchanged) {
|
||||
rotatedBitmap
|
||||
transformedBitmap
|
||||
} else {
|
||||
Bitmap.createBitmap(
|
||||
rotatedBitmap,
|
||||
transformedBitmap,
|
||||
cropRect.left,
|
||||
cropRect.top,
|
||||
cropRect.width(),
|
||||
cropRect.height(),
|
||||
)
|
||||
}
|
||||
if (croppedBitmap !== rotatedBitmap) {
|
||||
rotatedBitmap.recycle()
|
||||
if (croppedBitmap !== transformedBitmap) {
|
||||
transformedBitmap.recycle()
|
||||
}
|
||||
|
||||
val editedMediaDir = File(context.cacheDir, EDITED_MEDIA_DIR_NAME).apply { mkdirs() }
|
||||
@@ -141,11 +141,22 @@ internal fun exportedMimeTypeFor(sourceMimeType: String?): String {
|
||||
}
|
||||
}
|
||||
|
||||
private fun Bitmap.rotateQuarterTurns(quarterTurns: Int): Bitmap {
|
||||
val normalizedTurns = (quarterTurns % 4 + 4) % 4
|
||||
if (normalizedTurns == 0) return this
|
||||
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 {
|
||||
postRotate(normalizedTurns * 90f)
|
||||
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)
|
||||
}
|
||||
|
||||
+40
-7
@@ -26,6 +26,8 @@ data class AttachmentImageEditorState(
|
||||
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
|
||||
@@ -34,18 +36,32 @@ data class AttachmentImageEdits(
|
||||
get() = normalizedRotationQuarterTurns * 90
|
||||
|
||||
val hasChanges: Boolean
|
||||
get() = cropRect != NormalizedCropRect.default() || normalizedRotationQuarterTurns != 0
|
||||
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 = NormalizedCropRect(
|
||||
left = cropRect.top,
|
||||
top = 1f - cropRect.right,
|
||||
right = cropRect.bottom,
|
||||
bottom = 1f - cropRect.left,
|
||||
)
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -135,6 +151,23 @@ data class NormalizedCropRect(
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
+10
@@ -75,6 +75,16 @@ open class AttachmentImageEditorStateProvider : PreviewParameterProvider<Attachm
|
||||
),
|
||||
previewDebug = true,
|
||||
),
|
||||
anAttachmentImageEditorState(
|
||||
edits = AttachmentImageEdits(
|
||||
cropRect = caterpillarCrop,
|
||||
).flipHorizontally(),
|
||||
),
|
||||
anAttachmentImageEditorState(
|
||||
edits = AttachmentImageEdits(
|
||||
cropRect = caterpillarCrop,
|
||||
).flipVertically(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+61
-17
@@ -10,7 +10,6 @@ package io.element.android.features.messages.impl.attachments.preview.imageedito
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -24,7 +23,6 @@ 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.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -90,6 +88,8 @@ fun AttachmentImageEditorView(
|
||||
state: AttachmentImageEditorState,
|
||||
onCropRectChange: (NormalizedCropRect) -> Unit,
|
||||
onRotateClick: () -> Unit,
|
||||
onFlipHorizontallyClick: () -> Unit,
|
||||
onFlipVerticallyClick: () -> Unit,
|
||||
onResetClick: () -> Unit,
|
||||
onCancelClick: () -> Unit,
|
||||
onDoneClick: () -> Unit,
|
||||
@@ -101,8 +101,18 @@ fun AttachmentImageEditorView(
|
||||
state.edits.rotationDegrees,
|
||||
state.edits.rotationDegrees,
|
||||
)
|
||||
val rotateButtonBackground = ElementTheme.colors.bgCanvasDefault
|
||||
|
||||
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 = {
|
||||
@@ -158,37 +168,57 @@ fun AttachmentImageEditorView(
|
||||
onClick = onResetClick,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentAlignment = Alignment.Center,
|
||||
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
|
||||
.background(
|
||||
color = rotateButtonBackground,
|
||||
shape = CircleShape,
|
||||
)
|
||||
.border(1.dp, ElementTheme.colors.borderInteractiveSecondary, CircleShape)
|
||||
.clearAndSetSemantics {
|
||||
contentDescription = rotateContentDescription
|
||||
stateDescription = rotationStateDescription
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.size(22.dp),
|
||||
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_done),
|
||||
text = stringResource(CommonStrings.action_save),
|
||||
onClick = onDoneClick,
|
||||
)
|
||||
}
|
||||
@@ -204,6 +234,8 @@ private fun BoxScope.CropEditorCanvas(
|
||||
) {
|
||||
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) }
|
||||
|
||||
@@ -257,6 +289,10 @@ private fun BoxScope.CropEditorCanvas(
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.requiredSize(imageLayoutWidthDp, imageLayoutHeightDp)
|
||||
.graphicsLayer {
|
||||
scaleX = flipScaleX
|
||||
scaleY = flipScaleY
|
||||
}
|
||||
.graphicsLayer { rotationZ = rotationQuarterTurns * 90f },
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
@@ -266,6 +302,10 @@ private fun BoxScope.CropEditorCanvas(
|
||||
contentDescription = stringResource(CommonStrings.common_image),
|
||||
modifier = Modifier
|
||||
.requiredSize(imageLayoutWidthDp, imageLayoutHeightDp)
|
||||
.graphicsLayer {
|
||||
scaleX = flipScaleX
|
||||
scaleY = flipScaleY
|
||||
}
|
||||
.graphicsLayer { rotationZ = rotationQuarterTurns * 90f },
|
||||
contentScale = ContentScale.Fit,
|
||||
onState = { painterState ->
|
||||
@@ -316,11 +356,13 @@ private fun BoxScope.CropEditorCanvas(
|
||||
) { 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 / size.width.toFloat(),
|
||||
deltaY = dragAmount.y / size.height.toFloat(),
|
||||
deltaX = dragAmount.x / gestureAreaWidth,
|
||||
deltaY = dragAmount.y / gestureAreaHeight,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -640,6 +682,8 @@ internal fun AttachmentImageEditorViewPreview(
|
||||
state = state,
|
||||
onCropRectChange = {},
|
||||
onRotateClick = {},
|
||||
onFlipHorizontallyClick = {},
|
||||
onFlipVerticallyClick = {},
|
||||
onResetClick = {},
|
||||
onCancelClick = {},
|
||||
onDoneClick = {},
|
||||
|
||||
+3
@@ -36,6 +36,7 @@ import kotlin.math.roundToLong
|
||||
|
||||
@AssistedInject
|
||||
class DefaultMediaOptimizationSelectorPresenter(
|
||||
@Assisted private val index: Int,
|
||||
@Assisted private val localMedia: LocalMedia,
|
||||
@Assisted private val sendAsFile: Boolean,
|
||||
private val maxUploadSizeProvider: MaxUploadSizeProvider,
|
||||
@@ -48,6 +49,7 @@ class DefaultMediaOptimizationSelectorPresenter(
|
||||
@AssistedFactory
|
||||
interface Factory : MediaOptimizationSelectorPresenter.Factory {
|
||||
override fun create(
|
||||
index: Int,
|
||||
localMedia: LocalMedia,
|
||||
sendAsFile: Boolean,
|
||||
): DefaultMediaOptimizationSelectorPresenter
|
||||
@@ -183,6 +185,7 @@ class DefaultMediaOptimizationSelectorPresenter(
|
||||
}
|
||||
|
||||
return MediaOptimizationSelectorState(
|
||||
index = index,
|
||||
maxUploadSize = maxUploadSize,
|
||||
videoSizeEstimations = videoSizeEstimations,
|
||||
isImageOptimizationEnabled = selectedImageOptimization.dataOrNull(),
|
||||
|
||||
+1
@@ -14,6 +14,7 @@ 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?,
|
||||
|
||||
+1
@@ -35,6 +35,7 @@ internal fun MessagesViewWithIdentityChangePreview(
|
||||
onBackClick = {},
|
||||
onRoomDetailsClick = {},
|
||||
onEventContentClick = { _, _ -> false },
|
||||
onGalleryEventItemClick = { _, _, _ -> false },
|
||||
onUserDataClick = {},
|
||||
onLinkClick = { _, _ -> },
|
||||
onSendLocationClick = {},
|
||||
|
||||
+6
-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() {
|
||||
|
||||
+8
-13
@@ -16,7 +16,8 @@ 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
|
||||
@@ -34,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
|
||||
|
||||
@@ -73,8 +73,9 @@ 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 },
|
||||
scrollable = false,
|
||||
@@ -106,25 +107,21 @@ private fun AttachmentSourcePickerMenu(
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
if (state.canShareLocation) {
|
||||
ListItem(
|
||||
@@ -134,7 +131,6 @@ private fun AttachmentSourcePickerMenu(
|
||||
},
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.LocationPin())),
|
||||
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_location)) },
|
||||
style = ListItemStyle.Primary,
|
||||
)
|
||||
}
|
||||
ListItem(
|
||||
@@ -144,14 +140,12 @@ private fun AttachmentSourcePickerMenu(
|
||||
},
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Polls())),
|
||||
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_poll)) },
|
||||
style = ListItemStyle.Primary,
|
||||
)
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -159,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 = {},
|
||||
|
||||
+55
-13
@@ -34,7 +34,6 @@ import im.vector.app.features.analytics.plan.Interaction
|
||||
import io.element.android.features.location.api.LocationService
|
||||
import io.element.android.features.messages.impl.MessagesNavigator
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.attachments.Attachment.Media
|
||||
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
|
||||
import io.element.android.features.messages.impl.draft.ComposerDraftService
|
||||
import io.element.android.features.messages.impl.messagecomposer.suggestions.RoomAliasSuggestionsDataSource
|
||||
@@ -48,7 +47,10 @@ 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
|
||||
@@ -109,7 +111,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 isInThread: Boolean,
|
||||
@Assisted private val threadRoot: ThreadId?,
|
||||
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
||||
private val room: JoinedRoom,
|
||||
private val mediaPickerProvider: PickerProvider,
|
||||
@@ -132,16 +134,18 @@ class MessageComposerPresenter(
|
||||
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
|
||||
private val notificationConversationService: NotificationConversationService,
|
||||
private val slashCommandService: SlashCommandService,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
) : Presenter<MessageComposerState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(
|
||||
timelineController: TimelineController,
|
||||
navigator: MessagesNavigator,
|
||||
isInThread: Boolean,
|
||||
threadRoot: ThreadId?,
|
||||
): MessageComposerPresenter
|
||||
}
|
||||
|
||||
private val isInThread = threadRoot != null
|
||||
private val mediaSender = mediaSenderFactory.create(timelineMode = timelineController.mainTimelineMode())
|
||||
|
||||
private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
|
||||
@@ -175,10 +179,19 @@ class MessageComposerPresenter(
|
||||
canShareLocation.value = locationService.isServiceAvailable()
|
||||
}
|
||||
|
||||
val isSendGalleryMessagesEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.SendGalleryMessages)
|
||||
.collectAsState(initial = false)
|
||||
|
||||
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType ->
|
||||
handlePickedMedia(uri, mimeType)
|
||||
}
|
||||
val filesPicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri, mimeType ->
|
||||
val galleryMultiMediaPicker = mediaPickerProvider.registerGalleryMultiPicker { uris ->
|
||||
handlePickedMediaList(uris)
|
||||
}
|
||||
val filesPicker = mediaPickerProvider.registerFileMultiPicker(AnyMimeTypes) { uris ->
|
||||
handlePickedMediaList(uris, sendAsFile = true)
|
||||
}
|
||||
val fileSinglePicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri, mimeType ->
|
||||
handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream, sendAsFile = true)
|
||||
}
|
||||
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
|
||||
@@ -234,8 +247,7 @@ class MessageComposerPresenter(
|
||||
LaunchedEffect(Unit) {
|
||||
val draft = draftService.loadDraft(
|
||||
roomId = room.roomId,
|
||||
// TODO support threads in composer
|
||||
threadRoot = null,
|
||||
threadRoot = threadRoot,
|
||||
isVolatile = false
|
||||
)
|
||||
if (draft != null) {
|
||||
@@ -265,7 +277,7 @@ class MessageComposerPresenter(
|
||||
is MessageComposerEvent.SendUri -> {
|
||||
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
|
||||
sessionCoroutineScope.sendAttachment(
|
||||
attachment = Media(
|
||||
attachment = Attachment.Media(
|
||||
localMedia = localMediaFactory.createFromUri(
|
||||
uri = event.uri,
|
||||
mimeType = null,
|
||||
@@ -288,11 +300,19 @@ class MessageComposerPresenter(
|
||||
MessageComposerEvent.DismissAttachmentMenu -> showAttachmentSourcePicker = false
|
||||
MessageComposerEvent.PickAttachmentSource.FromGallery -> localCoroutineScope.launch {
|
||||
showAttachmentSourcePicker = false
|
||||
galleryMediaPicker.launch()
|
||||
if (isSendGalleryMessagesEnabled) {
|
||||
galleryMultiMediaPicker.launch()
|
||||
} else {
|
||||
galleryMediaPicker.launch()
|
||||
}
|
||||
}
|
||||
MessageComposerEvent.PickAttachmentSource.FromFiles -> localCoroutineScope.launch {
|
||||
showAttachmentSourcePicker = false
|
||||
filesPicker.launch()
|
||||
if (isSendGalleryMessagesEnabled) {
|
||||
filesPicker.launch()
|
||||
} else {
|
||||
fileSinglePicker.launch()
|
||||
}
|
||||
}
|
||||
MessageComposerEvent.PickAttachmentSource.PhotoFromCamera -> localCoroutineScope.launch {
|
||||
showAttachmentSourcePicker = false
|
||||
@@ -622,6 +642,30 @@ class MessageComposerPresenter(
|
||||
messageComposerContext.composerMode = MessageComposerMode.Normal
|
||||
}
|
||||
|
||||
private fun handlePickedMediaList(
|
||||
uris: List<Uri>,
|
||||
sendAsFile: Boolean = false,
|
||||
) {
|
||||
if (uris.isEmpty()) return
|
||||
if (uris.size == 1) {
|
||||
handlePickedMedia(uris.first(), sendAsFile = sendAsFile)
|
||||
return
|
||||
}
|
||||
val attachments = uris.map { uri ->
|
||||
val localMedia = localMediaFactory.createFromUri(
|
||||
uri = uri,
|
||||
mimeType = null,
|
||||
name = null,
|
||||
formattedFileSize = null,
|
||||
)
|
||||
Attachment.Media(localMedia, sendAsFile = sendAsFile)
|
||||
}.toImmutableList()
|
||||
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
|
||||
navigator.navigateToPreviewAttachments(attachments, inReplyToEventId)
|
||||
|
||||
messageComposerContext.composerMode = MessageComposerMode.Normal
|
||||
}
|
||||
|
||||
private suspend fun sendMedia(
|
||||
uri: Uri,
|
||||
mimeType: String,
|
||||
@@ -652,8 +696,7 @@ class MessageComposerPresenter(
|
||||
roomId = room.roomId,
|
||||
draft = draft,
|
||||
isVolatile = isVolatile,
|
||||
// TODO support threads in composer
|
||||
threadRoot = null,
|
||||
threadRoot = threadRoot,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -816,8 +859,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) {
|
||||
|
||||
+4
@@ -53,6 +53,7 @@ class PinnedMessagesListNode(
|
||||
) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator {
|
||||
interface Callback : Plugin {
|
||||
fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean)
|
||||
fun handleGalleryItemClick(event: TimelineItem.Event, galleryItemIndex: Int, canUseOverlay: Boolean)
|
||||
fun navigateToRoomMemberDetails(userId: UserId)
|
||||
fun viewInTimeline(eventId: EventId)
|
||||
fun handlePermalinkClick(data: PermalinkData.RoomLink)
|
||||
@@ -119,6 +120,9 @@ class PinnedMessagesListNode(
|
||||
onEventClick = {
|
||||
callback.handleEventClick(it, canUseOverlay)
|
||||
},
|
||||
onGalleryItemClick = { event, index ->
|
||||
callback.handleGalleryItemClick(event, index, canUseOverlay)
|
||||
},
|
||||
onUserDataClick = { callback.navigateToRoomMemberDetails(it.userId) },
|
||||
onLinkClick = { link -> onLinkClick(context, link.url) },
|
||||
onLinkLongClick = {
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
|
||||
+10
-1
@@ -60,6 +60,7 @@ fun PinnedMessagesListView(
|
||||
state: PinnedMessagesListState,
|
||||
onBackClick: () -> Unit,
|
||||
onEventClick: (event: TimelineItem.Event) -> Unit,
|
||||
onGalleryItemClick: (event: TimelineItem.Event, index: Int) -> Unit,
|
||||
onUserDataClick: (MatrixUser) -> Unit,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
@@ -81,6 +82,7 @@ fun PinnedMessagesListView(
|
||||
PinnedMessagesListContent(
|
||||
state = state,
|
||||
onEventClick = onEventClick,
|
||||
onGalleryItemClick = onGalleryItemClick,
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
@@ -111,6 +113,7 @@ private fun PinnedMessagesListTopBar(
|
||||
private fun PinnedMessagesListContent(
|
||||
state: PinnedMessagesListState,
|
||||
onEventClick: (event: TimelineItem.Event) -> Unit,
|
||||
onGalleryItemClick: (event: TimelineItem.Event, index: Int) -> Unit,
|
||||
onUserDataClick: (MatrixUser) -> Unit,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
@@ -131,6 +134,7 @@ private fun PinnedMessagesListContent(
|
||||
state = state,
|
||||
displayThreadSummaries = state.displayThreadSummaries,
|
||||
onEventClick = onEventClick,
|
||||
onGalleryItemClick = onGalleryItemClick,
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
@@ -169,6 +173,7 @@ private fun PinnedMessagesListLoaded(
|
||||
state: PinnedMessagesListState.Filled,
|
||||
displayThreadSummaries: Boolean,
|
||||
onEventClick: (event: TimelineItem.Event) -> Unit,
|
||||
onGalleryItemClick: (event: TimelineItem.Event, index: Int) -> Unit,
|
||||
onUserDataClick: (MatrixUser) -> Unit,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
@@ -217,7 +222,6 @@ private fun PinnedMessagesListLoaded(
|
||||
timelineItem = timelineItem,
|
||||
timelineMode = Timeline.Mode.PinnedEvents,
|
||||
timelineRoomInfo = state.timelineRoomInfo,
|
||||
renderReadReceipts = false,
|
||||
timelineProtectionState = state.timelineProtectionState,
|
||||
isLastOutgoingMessage = false,
|
||||
focusedEventId = null,
|
||||
@@ -227,6 +231,7 @@ private fun PinnedMessagesListLoaded(
|
||||
},
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
onContentClick = onEventClick,
|
||||
onGalleryItemClick = onGalleryItemClick,
|
||||
onLongClick = ::onMessageLongClick,
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
inReplyToClick = {},
|
||||
@@ -246,6 +251,7 @@ private fun PinnedMessagesListLoaded(
|
||||
event = event,
|
||||
timelineProtectionState = state.timelineProtectionState,
|
||||
onContentClick = { onEventClick(event) },
|
||||
onGalleryItemClick = { index -> onGalleryItemClick(event, index) },
|
||||
onLongClick = { onMessageLongClick(event) },
|
||||
onLinkClick = { link ->
|
||||
state.linkState.eventSink(LinkEvent.OnLinkClick(link))
|
||||
@@ -269,6 +275,7 @@ private fun TimelineItemEventContentViewWrapper(
|
||||
event: TimelineItem.Event,
|
||||
timelineProtectionState: TimelineProtectionState,
|
||||
onContentClick: () -> Unit,
|
||||
onGalleryItemClick: (index: Int) -> Unit,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
onLongClick: (() -> Unit)?,
|
||||
@@ -286,6 +293,7 @@ private fun TimelineItemEventContentViewWrapper(
|
||||
content = event.content,
|
||||
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
|
||||
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
|
||||
onGalleryItemClick = onGalleryItemClick,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
eventSink = { },
|
||||
@@ -305,6 +313,7 @@ internal fun PinnedMessagesListViewPreview(@PreviewParameter(PinnedMessagesListS
|
||||
state = state,
|
||||
onBackClick = {},
|
||||
onEventClick = { },
|
||||
onGalleryItemClick = { _, _ -> },
|
||||
onUserDataClick = {},
|
||||
onLinkClick = {},
|
||||
onLinkLongClick = {},
|
||||
|
||||
+24
-1
@@ -67,6 +67,7 @@ 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.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
|
||||
@@ -112,7 +113,7 @@ class ThreadedMessagesNode(
|
||||
this.timelineController = timelineController
|
||||
return presenterFactory.create(
|
||||
navigator = this,
|
||||
composerPresenter = messageComposerPresenterFactory.create(timelineController, this, isInThread = true),
|
||||
composerPresenter = messageComposerPresenterFactory.create(timelineController, this, threadRoot = inputs.threadRootEventId),
|
||||
timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this),
|
||||
// TODO add special processor for threaded timeline
|
||||
actionListPresenter = actionListPresenterFactory.create(
|
||||
@@ -125,6 +126,7 @@ class ThreadedMessagesNode(
|
||||
|
||||
interface Callback : Plugin {
|
||||
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)
|
||||
@@ -138,6 +140,8 @@ class ThreadedMessagesNode(
|
||||
fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean)
|
||||
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
|
||||
fun navigateToDeveloperSettings()
|
||||
|
||||
fun navigateToAvatarPreview(username: String, avatarUrl: String)
|
||||
}
|
||||
|
||||
override fun onBuilt() {
|
||||
@@ -286,6 +290,20 @@ class ThreadedMessagesNode(
|
||||
}
|
||||
} == 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
|
||||
}
|
||||
}
|
||||
} == true
|
||||
},
|
||||
onUserDataClick = callback::navigateToRoomMemberDetails,
|
||||
onLinkClick = { url, customTab ->
|
||||
onLinkClick(
|
||||
@@ -315,6 +333,11 @@ class ThreadedMessagesNode(
|
||||
else -> state.roomMemberModerationState.eventSink(RoomMemberModerationEvents.ProcessAction(action, target))
|
||||
}
|
||||
},
|
||||
onAvatarClick = { user ->
|
||||
user.avatarUrl?.let { url ->
|
||||
callback.navigateToAvatarPreview(user.getBestName(), url)
|
||||
}
|
||||
},
|
||||
modifier = Modifier,
|
||||
)
|
||||
|
||||
|
||||
+13
-1
@@ -9,8 +9,10 @@ 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
|
||||
@@ -18,9 +20,12 @@ import io.element.android.features.messages.impl.utils.messagesummary.MessageSum
|
||||
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
|
||||
@@ -95,15 +100,20 @@ class ThreadsListPresenter(
|
||||
|
||||
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 since there is nothing else to load, current status: $paginationStatus")
|
||||
Timber.d("Not paginating, current status: $paginationStatus")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,6 +124,7 @@ class ThreadsListPresenter(
|
||||
roomName = roomInfo.name ?: room.roomId.value,
|
||||
roomAvatarUrl = roomInfo.avatarUrl,
|
||||
isRoomTombstoned = roomInfo.successorRoom != null,
|
||||
heroes = heroes,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
@@ -124,6 +135,7 @@ data class ThreadsListState(
|
||||
val roomName: String,
|
||||
val roomAvatarUrl: String?,
|
||||
val isRoomTombstoned: Boolean,
|
||||
val heroes: ImmutableList<AvatarData>,
|
||||
val threads: ImmutableList<ThreadListRowItem>,
|
||||
val eventSink: (ThreadsListEvents) -> Unit,
|
||||
)
|
||||
|
||||
+11
-6
@@ -67,6 +67,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
|
||||
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
|
||||
|
||||
@@ -102,7 +103,10 @@ fun ThreadsListView(
|
||||
url = state.roomAvatarUrl,
|
||||
size = AvatarSize.CurrentUserTopBar,
|
||||
),
|
||||
avatarType = AvatarType.Room(isTombstoned = state.isRoomTombstoned),
|
||||
avatarType = AvatarType.Room(
|
||||
heroes = state.heroes,
|
||||
isTombstoned = state.isRoomTombstoned,
|
||||
),
|
||||
contentDescription = null,
|
||||
)
|
||||
Column {
|
||||
@@ -162,14 +166,14 @@ private fun ScrollHelper(
|
||||
val lastVisibleItemIndex by remember {
|
||||
derivedStateOf { listState.firstVisibleItemIndex + listState.layoutInfo.visibleItemsInfo.size - 1 }
|
||||
}
|
||||
val needsPagination by remember {
|
||||
val shouldPaginate by remember {
|
||||
derivedStateOf {
|
||||
val canLoadNewItems = listState.isScrollInProgress || listState.firstVisibleItemScrollOffset == 0
|
||||
canLoadNewItems && lastVisibleItemIndex == listState.layoutInfo.totalItemsCount - 1
|
||||
val canLoadNewItems = listState.isScrollInProgress || listState.layoutInfo.totalItemsCount == 0
|
||||
canLoadNewItems && lastVisibleItemIndex >= listState.layoutInfo.totalItemsCount - 1
|
||||
}
|
||||
}
|
||||
LaunchedEffect(needsPagination, lastVisibleItemIndex) {
|
||||
if (needsPagination) {
|
||||
LaunchedEffect(shouldPaginate, lastVisibleItemIndex) {
|
||||
if (shouldPaginate) {
|
||||
updatedOnPaginate()
|
||||
delay(400L)
|
||||
}
|
||||
@@ -323,6 +327,7 @@ internal fun ThreadsListViewPreview() {
|
||||
roomId = RoomId("!room-id:server"),
|
||||
roomName = ROOM_NAME,
|
||||
roomAvatarUrl = null,
|
||||
heroes = persistentListOf(),
|
||||
threads = List(10) { aThreadListRowItem(threadId = ThreadId("\$thread-$it")) }.toImmutableList(),
|
||||
isRoomTombstoned = false,
|
||||
eventSink = {},
|
||||
|
||||
+2
@@ -25,6 +25,8 @@ sealed interface TimelineEvent {
|
||||
|
||||
data object HideShieldDialog : TimelineEvent
|
||||
|
||||
data object MarkAllAsRead : TimelineEvent
|
||||
|
||||
/**
|
||||
* Events coming from a timeline item.
|
||||
*/
|
||||
|
||||
+91
-18
@@ -33,6 +33,7 @@ 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.typing.TypingNotificationState
|
||||
import io.element.android.features.messages.impl.userEventPermissions
|
||||
@@ -96,6 +97,7 @@ class TimelinePresenter(
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val liveLocationShareManager: ActiveLiveLocationShareManager,
|
||||
private val markAsFullyRead: MarkAsFullyRead,
|
||||
) : Presenter<TimelineState> {
|
||||
private val tag = "TimelinePresenter"
|
||||
|
||||
@@ -134,13 +136,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 renderReadReceipts by remember {
|
||||
sessionPreferencesStore.isRenderReadReceiptsEnabled()
|
||||
}.collectAsState(initial = true)
|
||||
val isLive by remember {
|
||||
timelineController.isLive()
|
||||
}.collectAsState(initial = true)
|
||||
@@ -148,8 +153,8 @@ class TimelinePresenter(
|
||||
val displayThreadSummaries by produceState(false) {
|
||||
value = featureFlagService.isFeatureEnabled(FeatureFlags.Threads)
|
||||
}
|
||||
val displayFloatingDateBadge by produceState(false) {
|
||||
value = featureFlagService.isFeatureEnabled(FeatureFlags.FloatingDateBadge)
|
||||
val displayJumpToUnread by produceState(false) {
|
||||
value = featureFlagService.isFeatureEnabled(FeatureFlags.JumpToUnread)
|
||||
}
|
||||
|
||||
fun handleEvent(event: TimelineEvent) {
|
||||
@@ -224,6 +229,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))
|
||||
@@ -255,13 +268,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
|
||||
@@ -275,7 +293,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
|
||||
@@ -316,14 +394,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,
|
||||
displayFloatingDateBadge = displayFloatingDateBadge,
|
||||
displayJumpToUnread = displayJumpToUnread,
|
||||
jumpToUnread = jumpToUnread.value,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
@@ -385,7 +463,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) {
|
||||
@@ -404,12 +482,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
-2
@@ -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,7 +33,8 @@ data class TimelineState(
|
||||
val messageShieldDialogData: MessageShieldData?,
|
||||
val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState,
|
||||
val displayThreadSummaries: Boolean,
|
||||
val displayFloatingDateBadge: Boolean,
|
||||
val displayJumpToUnread: Boolean,
|
||||
val jumpToUnread: JumpToUnreadState,
|
||||
val eventSink: (TimelineEvent) -> Unit,
|
||||
) {
|
||||
private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event
|
||||
@@ -83,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
|
||||
}
|
||||
|
||||
+6
-5
@@ -51,14 +51,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,
|
||||
displayFloatingDateBadge: 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
|
||||
@@ -71,14 +72,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,
|
||||
displayFloatingDateBadge = displayFloatingDateBadge,
|
||||
displayJumpToUnread = displayJumpToUnread,
|
||||
jumpToUnread = jumpToUnread,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
}
|
||||
|
||||
+296
-42
@@ -10,22 +10,34 @@ 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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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,16 +51,27 @@ 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
|
||||
@@ -65,20 +88,23 @@ 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.a11y.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
|
||||
@@ -97,6 +123,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,
|
||||
@@ -106,6 +133,7 @@ fun TimelineView(
|
||||
modifier: Modifier = Modifier,
|
||||
lazyListState: LazyListState = rememberLazyListState(),
|
||||
forceJumpToBottomVisibility: Boolean = false,
|
||||
forceJumpToReadMarkerVisibility: Boolean = false,
|
||||
nestedScrollConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(),
|
||||
floatingDateTopOffset: Dp = 0.dp,
|
||||
) {
|
||||
@@ -125,12 +153,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))
|
||||
}
|
||||
@@ -158,7 +191,7 @@ fun TimelineView(
|
||||
.nestedScroll(nestedScrollConnection)
|
||||
.testTag(TestTags.timeline),
|
||||
state = lazyListState,
|
||||
reverseLayout = useReverseLayout,
|
||||
reverseLayout = true,
|
||||
contentPadding = PaddingValues(top = 64.dp, bottom = 8.dp),
|
||||
) {
|
||||
items(
|
||||
@@ -171,7 +204,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,
|
||||
@@ -179,6 +211,7 @@ fun TimelineView(
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = ::onLinkLongClick,
|
||||
onContentClick = onContentClick,
|
||||
onGalleryItemClick = onGalleryItemClick,
|
||||
onLongClick = onMessageLongClick,
|
||||
inReplyToClick = ::inReplyToClick,
|
||||
onReactionClick = onReactionClick,
|
||||
@@ -205,22 +238,25 @@ 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,
|
||||
)
|
||||
|
||||
if (state.displayFloatingDateBadge && useReverseLayout) {
|
||||
FloatingDateBadgeOverlay(
|
||||
lazyListState = lazyListState,
|
||||
timelineItems = state.timelineItems,
|
||||
isLive = state.isLive,
|
||||
topOffset = floatingDateTopOffset,
|
||||
)
|
||||
}
|
||||
FloatingDateBadgeOverlay(
|
||||
lazyListState = lazyListState,
|
||||
timelineItems = state.timelineItems,
|
||||
isLive = state.isLive,
|
||||
topOffset = floatingDateTopOffset,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,10 +325,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 } }
|
||||
@@ -301,6 +342,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) }
|
||||
|
||||
/**
|
||||
@@ -327,6 +384,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)
|
||||
@@ -343,8 +410,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)
|
||||
}
|
||||
@@ -358,43 +428,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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -429,7 +601,89 @@ internal fun TimelineViewPreview(
|
||||
onReactionLongClick = { _, _ -> },
|
||||
onMoreReactionsClick = {},
|
||||
onReadReceiptClick = {},
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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
@@ -49,6 +49,7 @@ internal fun TimelineViewMessageShieldPreview() = ElementPreview {
|
||||
onReactionLongClick = { _, _ -> },
|
||||
onMoreReactionsClick = {},
|
||||
onReadReceiptClick = {},
|
||||
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 = {},
|
||||
|
||||
+6
-1
@@ -27,6 +27,8 @@ 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
|
||||
@@ -102,7 +104,10 @@ internal fun BoxScope.FloatingDateBadgeOverlay(
|
||||
visible = showBadge,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = 8.dp + topOffset),
|
||||
.padding(top = 8.dp + topOffset)
|
||||
.clearAndSetSemantics {
|
||||
hideFromAccessibility()
|
||||
},
|
||||
enter = fadeIn(animationSpec = tween(150)),
|
||||
exit = fadeOut(animationSpec = tween(300)),
|
||||
) {
|
||||
|
||||
+9
-1
@@ -43,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
|
||||
@@ -77,9 +78,16 @@ fun TimelineEventTimestampView(
|
||||
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)
|
||||
|
||||
+6
-9
@@ -52,7 +52,6 @@ internal fun TimelineItemCallNotifyView(
|
||||
timelineRoomInfo: TimelineRoomInfo,
|
||||
event: TimelineItem.Event,
|
||||
content: TimelineItemRtcNotificationContent,
|
||||
renderReadReceipts: Boolean,
|
||||
isLastOutgoingMessage: Boolean,
|
||||
onLongClick: (TimelineItem.Event) -> Unit,
|
||||
onReadReceiptsClick: (TimelineItem.Event) -> Unit,
|
||||
@@ -106,7 +105,6 @@ internal fun TimelineItemCallNotifyView(
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
receipts = event.readReceiptState.receipts,
|
||||
),
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
onReadReceiptsClick = { onReadReceiptsClick(event) },
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
@@ -146,8 +144,10 @@ private fun getIcon(
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemCallNotifyViewPreview() = ElementPreview {
|
||||
val readReceiptState = aTimelineItemReadReceipts(
|
||||
receipts = List(3) { aReadReceiptData(it) },
|
||||
val readReceiptState = mutableListOf(
|
||||
aTimelineItemReadReceipts(
|
||||
receipts = List(3) { aReadReceiptData(it) },
|
||||
)
|
||||
)
|
||||
Column(modifier = Modifier.padding(bottom = 16.dp)) {
|
||||
listOf(false, true).forEach { isDm ->
|
||||
@@ -162,13 +162,10 @@ internal fun TimelineItemCallNotifyViewPreview() = ElementPreview {
|
||||
timelineRoomInfo = aTimelineRoomInfo(isDm = isDm),
|
||||
event = aTimelineItemEvent(
|
||||
content = content,
|
||||
readReceiptState = readReceiptState,
|
||||
// Only display read receipts for the first item
|
||||
readReceiptState = readReceiptState.removeFirstOrNull() ?: aTimelineItemReadReceipts(),
|
||||
),
|
||||
content = content,
|
||||
// Render read receipts for the first item only
|
||||
renderReadReceipts = !isDm &&
|
||||
callIntent == CallIntent.AUDIO &&
|
||||
state == RtcNotificationState.Started,
|
||||
isLastOutgoingMessage = false,
|
||||
onLongClick = {},
|
||||
onReadReceiptsClick = {},
|
||||
|
||||
+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",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
+200
-19
@@ -39,7 +39,9 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
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
|
||||
@@ -49,9 +51,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
|
||||
@@ -68,12 +73,16 @@ 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
|
||||
@@ -91,6 +100,7 @@ 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
|
||||
@@ -106,6 +116,7 @@ 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
|
||||
@@ -122,7 +133,9 @@ 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.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
|
||||
@@ -142,10 +155,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,
|
||||
@@ -166,13 +179,14 @@ fun TimelineItemEventRow(
|
||||
content = event.content,
|
||||
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
|
||||
onContentClick = onContentClick,
|
||||
onGalleryItemClick = onGalleryItemClick,
|
||||
onLongClick = onLongClick,
|
||||
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
eventSink = eventSink,
|
||||
modifier = contentModifier,
|
||||
onContentLayoutChange = onContentLayoutChange
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
)
|
||||
},
|
||||
) {
|
||||
@@ -296,7 +310,6 @@ fun TimelineItemEventRow(
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
receipts = event.readReceiptState.receipts,
|
||||
),
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
onReadReceiptsClick = { onReadReceiptClick(event) },
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
@@ -651,23 +664,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 {}
|
||||
@@ -778,6 +812,8 @@ private fun MessageEventBubbleContent(
|
||||
val timestampPosition = when (val content = event.content) {
|
||||
is TimelineItemImageContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
|
||||
is TimelineItemVideoContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
|
||||
is TimelineItemGalleryContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Below
|
||||
is TimelineItemAttachmentsContent -> TimestampPosition.Below
|
||||
is TimelineItemStickerContent -> TimestampPosition.Overlay
|
||||
is TimelineItemLocationContent -> {
|
||||
val content = content.ensureActiveLiveLocation()
|
||||
@@ -792,6 +828,8 @@ private fun MessageEventBubbleContent(
|
||||
val paddingBehaviour = when (event.content) {
|
||||
is TimelineItemImageContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
|
||||
is TimelineItemVideoContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
|
||||
is TimelineItemGalleryContent -> ContentPadding.CaptionedMedia
|
||||
is TimelineItemAttachmentsContent -> ContentPadding.CaptionedMedia
|
||||
is TimelineItemStickerContent,
|
||||
is TimelineItemLocationContent -> ContentPadding.Media
|
||||
else -> ContentPadding.Textual
|
||||
@@ -881,6 +919,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() {
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemEventRowWithGalleryPreview(
|
||||
@PreviewParameter(TimelineItemEventContentForGalleryViewProvider::class) content: TimelineItemEventContent,
|
||||
) = ElementPreview {
|
||||
Column {
|
||||
sequenceOf(false, true).forEach { isMine ->
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
isMine = isMine,
|
||||
content = content,
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
-3
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+5
-16
@@ -34,7 +34,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.a11y.isTalkbackActive
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
|
||||
@Composable
|
||||
@@ -43,7 +42,6 @@ fun TimelineItemGroupedEventsRow(
|
||||
timelineMode: Timeline.Mode,
|
||||
timelineRoomInfo: TimelineRoomInfo,
|
||||
timelineProtectionState: TimelineProtectionState,
|
||||
renderReadReceipts: Boolean,
|
||||
isLastOutgoingMessage: Boolean,
|
||||
focusedEventId: EventId?,
|
||||
displayThreadSummaries: Boolean,
|
||||
@@ -70,6 +68,7 @@ fun TimelineItemGroupedEventsRow(
|
||||
eventSink = eventSink,
|
||||
modifier = contentModifier,
|
||||
onContentClick = null,
|
||||
onGalleryItemClick = {},
|
||||
onLongClick = null,
|
||||
onContentLayoutChange = onContentLayoutChange
|
||||
)
|
||||
@@ -89,7 +88,6 @@ fun TimelineItemGroupedEventsRow(
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
focusedEventId = focusedEventId,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
onClick = onClick,
|
||||
@@ -117,7 +115,6 @@ private fun TimelineItemGroupedEventsRowContent(
|
||||
timelineRoomInfo: TimelineRoomInfo,
|
||||
timelineProtectionState: TimelineProtectionState,
|
||||
focusedEventId: EventId?,
|
||||
renderReadReceipts: Boolean,
|
||||
isLastOutgoingMessage: Boolean,
|
||||
displayThreadSummaries: Boolean,
|
||||
onClick: (TimelineItem.Event) -> Unit,
|
||||
@@ -143,6 +140,7 @@ private fun TimelineItemGroupedEventsRowContent(
|
||||
eventSink = eventSink,
|
||||
modifier = contentModifier,
|
||||
onContentClick = null,
|
||||
onGalleryItemClick = {},
|
||||
onLongClick = null,
|
||||
onContentLayoutChange = onContentLayoutChange
|
||||
)
|
||||
@@ -161,19 +159,12 @@ private fun TimelineItemGroupedEventsRowContent(
|
||||
)
|
||||
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,6 +172,7 @@ private fun TimelineItemGroupedEventsRowContent(
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
onContentClick = onClick,
|
||||
onGalleryItemClick = { _, _ -> },
|
||||
onLongClick = onLongClick,
|
||||
inReplyToClick = inReplyToClick,
|
||||
onReactionClick = onReactionClick,
|
||||
@@ -193,14 +185,13 @@ private fun TimelineItemGroupedEventsRowContent(
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (renderReadReceipts) {
|
||||
} else if (timelineItem.aggregatedReadReceipts.isNotEmpty()) {
|
||||
TimelineItemReadReceiptView(
|
||||
state = ReadReceiptViewState(
|
||||
sendState = null,
|
||||
isLastOutgoingMessage = false,
|
||||
receipts = timelineItem.aggregatedReadReceipts,
|
||||
),
|
||||
renderReadReceipts = true,
|
||||
onReadReceiptsClick = onExpandGroupClick
|
||||
)
|
||||
}
|
||||
@@ -219,7 +210,6 @@ internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPrevi
|
||||
timelineRoomInfo = aTimelineRoomInfo(),
|
||||
timelineProtectionState = aTimelineProtectionState(),
|
||||
focusedEventId = events.events.first().eventId,
|
||||
renderReadReceipts = true,
|
||||
isLastOutgoingMessage = false,
|
||||
displayThreadSummaries = false,
|
||||
onClick = {},
|
||||
@@ -247,7 +237,6 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi
|
||||
timelineRoomInfo = aTimelineRoomInfo(),
|
||||
timelineProtectionState = aTimelineProtectionState(),
|
||||
focusedEventId = null,
|
||||
renderReadReceipts = true,
|
||||
isLastOutgoingMessage = false,
|
||||
displayThreadSummaries = false,
|
||||
onClick = {},
|
||||
|
||||
+4
-6
@@ -56,7 +56,6 @@ internal fun TimelineItemRow(
|
||||
timelineItem: TimelineItem,
|
||||
timelineMode: Timeline.Mode,
|
||||
timelineRoomInfo: TimelineRoomInfo,
|
||||
renderReadReceipts: Boolean,
|
||||
isLastOutgoingMessage: Boolean,
|
||||
timelineProtectionState: TimelineProtectionState,
|
||||
focusedEventId: EventId?,
|
||||
@@ -65,6 +64,7 @@ internal fun TimelineItemRow(
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
onContentClick: (TimelineItem.Event) -> Unit,
|
||||
onGalleryItemClick: (TimelineItem.Event, Int) -> Unit,
|
||||
onLongClick: (TimelineItem.Event) -> Unit,
|
||||
inReplyToClick: (EventId) -> Unit,
|
||||
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
|
||||
@@ -81,12 +81,13 @@ internal fun TimelineItemRow(
|
||||
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
|
||||
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
|
||||
onContentClick = { onContentClick(event) },
|
||||
onGalleryItemClick = { index -> onGalleryItemClick(event, index) },
|
||||
onLongClick = { onLongClick(event) },
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
eventSink = eventSink,
|
||||
modifier = contentModifier,
|
||||
onContentLayoutChange = onContentLayoutChange
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
)
|
||||
},
|
||||
) {
|
||||
@@ -114,7 +115,6 @@ internal fun TimelineItemRow(
|
||||
is TimelineItemStateContent, is TimelineItemLegacyCallInviteContent -> {
|
||||
TimelineItemStateEventRow(
|
||||
event = timelineItem,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
onClick = { onContentClick(timelineItem) },
|
||||
onReadReceiptsClick = onReadReceiptClick,
|
||||
@@ -127,7 +127,6 @@ internal fun TimelineItemRow(
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
event = timelineItem,
|
||||
content = timelineItem.content,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
onLongClick = onLongClick,
|
||||
onReadReceiptsClick = onReadReceiptClick,
|
||||
@@ -167,7 +166,6 @@ internal fun TimelineItemRow(
|
||||
event = timelineItem,
|
||||
timelineMode = timelineMode,
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
@@ -182,6 +180,7 @@ internal fun TimelineItemRow(
|
||||
onMoreReactionsClick = onMoreReactionsClick,
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
onSwipeToReply = { onSwipeToReply(timelineItem) },
|
||||
onGalleryItemClick = { index -> onGalleryItemClick(timelineItem, index) },
|
||||
eventSink = eventSink,
|
||||
eventContentView = { contentModifier, onContentLayoutChange ->
|
||||
eventContentView(timelineItem, contentModifier, onContentLayoutChange)
|
||||
@@ -196,7 +195,6 @@ internal fun TimelineItemRow(
|
||||
timelineMode = timelineMode,
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
focusedEventId = focusedEventId,
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
|
||||
+1
-3
@@ -39,7 +39,6 @@ import kotlinx.collections.immutable.persistentListOf
|
||||
@Composable
|
||||
fun TimelineItemStateEventRow(
|
||||
event: TimelineItem.Event,
|
||||
renderReadReceipts: Boolean,
|
||||
isLastOutgoingMessage: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
@@ -75,6 +74,7 @@ fun TimelineItemStateEventRow(
|
||||
onShowContentClick = {},
|
||||
eventSink = eventSink,
|
||||
onContentClick = null,
|
||||
onGalleryItemClick = {},
|
||||
onLongClick = null,
|
||||
modifier = Modifier.defaultTimelineContentPadding()
|
||||
)
|
||||
@@ -86,7 +86,6 @@ fun TimelineItemStateEventRow(
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
receipts = event.readReceiptState.receipts,
|
||||
),
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
onReadReceiptsClick = { onReadReceiptsClick(event) },
|
||||
)
|
||||
}
|
||||
@@ -104,7 +103,6 @@ internal fun TimelineItemStateEventRowPreview() = ElementPreview {
|
||||
receipts = persistentListOf(aReadReceiptData(0)),
|
||||
)
|
||||
),
|
||||
renderReadReceipts = true,
|
||||
isLastOutgoingMessage = false,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
|
||||
+5
-2
@@ -10,7 +10,8 @@ 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
|
||||
@@ -30,7 +31,9 @@ fun CustomReactionBottomSheet(
|
||||
onSelectEmoji: (EventOrTransactionId, Emoji) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
val sheetState = rememberBottomSheetState(
|
||||
initialValue = SheetValue.Hidden,
|
||||
)
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val target = state.target as? CustomReactionState.Target.Success
|
||||
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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",
|
||||
caption = "Files mixed with media",
|
||||
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",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.event
|
||||
|
||||
import android.text.SpannedString
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.model.event.AttachmentItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
|
||||
@Composable
|
||||
fun TimelineItemAttachmentsListView(
|
||||
content: TimelineItemAttachmentsContent,
|
||||
onGalleryItemClick: (Int) -> Unit,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
content.attachments.forEachIndexed { index, attachment ->
|
||||
Column {
|
||||
if (index > 0) {
|
||||
HorizontalDivider(
|
||||
color = ElementTheme.colors.borderInteractiveSecondary,
|
||||
)
|
||||
}
|
||||
AttachmentListItem(
|
||||
attachment = attachment,
|
||||
onClick = { onGalleryItemClick(index) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (content.showCaption) {
|
||||
HorizontalDivider(
|
||||
color = ElementTheme.colors.borderInteractiveSecondary,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val caption = if (LocalInspectionMode.current) {
|
||||
SpannedString(content.caption)
|
||||
} else {
|
||||
content.formattedCaption ?: SpannedString(content.caption)
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides ElementTheme.colors.textPrimary,
|
||||
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
|
||||
) {
|
||||
EditorStyledText(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 4.dp)
|
||||
.widthIn(min = 120.dp),
|
||||
text = caption,
|
||||
style = ElementRichTextEditorStyle.textStyle(),
|
||||
onLinkClickedListener = onLinkClick,
|
||||
onLinkLongClickedListener = onLinkLongClick,
|
||||
releaseOnDetach = false,
|
||||
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttachmentListItem(
|
||||
attachment: AttachmentItem,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val iconSize = 36.dp
|
||||
val thumbnailSize = 36L
|
||||
val spacing = 8.dp
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(iconSize)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(ElementTheme.colors.bgCanvasDefault),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (attachment.thumbnailSource != null) {
|
||||
val isVideo = attachment.mimeType.isMimeTypeVideo()
|
||||
AsyncImage(
|
||||
model = MediaRequestData(
|
||||
source = attachment.thumbnailSource,
|
||||
kind = MediaRequestData.Kind.Thumbnail(thumbnailSize),
|
||||
),
|
||||
contentDescription = attachment.filename,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(iconSize)
|
||||
.clip(RoundedCornerShape(4.dp)),
|
||||
)
|
||||
if (isVideo) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(iconSize)
|
||||
.background(
|
||||
color = Color.Black.copy(alpha = 0.3f),
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.VideoCallSolid(),
|
||||
contentDescription = stringResource(CommonStrings.common_video),
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val isImage = attachment.mimeType.isMimeTypeImage()
|
||||
val isVideo = attachment.mimeType.isMimeTypeVideo()
|
||||
val isAudio = attachment.mimeType.isMimeTypeAudio()
|
||||
val icon = when {
|
||||
isImage -> CompoundIcons.Image()
|
||||
isVideo -> CompoundIcons.VideoCall()
|
||||
isAudio -> CompoundIcons.Audio()
|
||||
else -> CompoundIcons.Attachment()
|
||||
}
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = ElementTheme.colors.iconPrimary,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(
|
||||
text = attachment.filename,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = "${attachment.fileExtension} • ${attachment.formattedFileSize}",
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemAttachmentsListViewPreview(
|
||||
@PreviewParameter(TimelineItemAttachmentsContentProvider::class) content: TimelineItemAttachmentsContent
|
||||
) = ElementPreview {
|
||||
TimelineItemAttachmentsListView(
|
||||
content = content,
|
||||
onGalleryItemClick = {},
|
||||
onLinkClick = {},
|
||||
onLinkLongClick = {},
|
||||
onContentLayoutChange = {},
|
||||
)
|
||||
}
|
||||
+20
@@ -14,10 +14,12 @@ import io.element.android.features.messages.impl.timeline.TimelineEvent
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.di.rememberPresenter
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
|
||||
@@ -40,6 +42,7 @@ fun TimelineItemEventContentView(
|
||||
content: TimelineItemEventContent,
|
||||
hideMediaContent: Boolean,
|
||||
onContentClick: (() -> Unit)?,
|
||||
onGalleryItemClick: ((Int) -> Unit),
|
||||
onLongClick: (() -> Unit)?,
|
||||
onShowContentClick: () -> Unit,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
@@ -90,6 +93,23 @@ fun TimelineItemEventContentView(
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
modifier = modifier,
|
||||
)
|
||||
is TimelineItemGalleryContent -> TimelineItemGalleryView(
|
||||
content = content,
|
||||
onGalleryItemClick = { index -> onGalleryItemClick(index) },
|
||||
onLongClick = onLongClick,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
modifier = modifier,
|
||||
)
|
||||
is TimelineItemAttachmentsContent -> TimelineItemAttachmentsListView(
|
||||
content = content,
|
||||
onGalleryItemClick = { index -> onGalleryItemClick(index) },
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
onContentLayoutChange = {},
|
||||
modifier = modifier,
|
||||
)
|
||||
is TimelineItemStickerContent -> TimelineItemStickerView(
|
||||
content = content,
|
||||
hideMediaContent = hideMediaContent,
|
||||
|
||||
+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.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,
|
||||
): 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(url = "", json = ""),
|
||||
thumbnailSource = null,
|
||||
width = width,
|
||||
height = height,
|
||||
thumbnailWidth = width,
|
||||
thumbnailHeight = height,
|
||||
blurhash = null,
|
||||
type = type,
|
||||
duration = duration,
|
||||
)
|
||||
}
|
||||
+384
@@ -0,0 +1,384 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.event
|
||||
|
||||
import android.text.SpannedString
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.model.event.GalleryItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
|
||||
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.bgSubtleTertiary
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||
import io.element.android.libraries.ui.utils.time.formatShort
|
||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
private const val MAX_TILES = 5
|
||||
private val GALLERY_WIDTH = 264.dp
|
||||
private val GRID_SPACING = 4.dp
|
||||
private val GROUP_CORNER_RADIUS = 6.dp
|
||||
|
||||
private val SINGLE_IMAGE_HEIGHT = 130.dp
|
||||
private val TWO_IMAGE_ROW_HEIGHT = 130.dp
|
||||
private val THREE_IMAGE_ROW_HEIGHT = 85.dp
|
||||
|
||||
@Composable
|
||||
fun TimelineItemGalleryView(
|
||||
content: TimelineItemGalleryContent,
|
||||
onGalleryItemClick: (Int) -> Unit,
|
||||
onLongClick: (() -> Unit)?,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val totalItems = content.items.size
|
||||
val showOverflow = totalItems > MAX_TILES
|
||||
val overflowCount = totalItems - MAX_TILES
|
||||
Column(modifier = modifier) {
|
||||
val containerModifier = Modifier.clip(RoundedCornerShape(GROUP_CORNER_RADIUS))
|
||||
Column(
|
||||
modifier = containerModifier.width(GALLERY_WIDTH),
|
||||
verticalArrangement = Arrangement.spacedBy(GRID_SPACING),
|
||||
) {
|
||||
when (totalItems) {
|
||||
0 -> Unit
|
||||
1 -> SingleItemLayout(
|
||||
item = content.items[0],
|
||||
onClick = { onGalleryItemClick(0) },
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
2 -> TwoItemLayout(
|
||||
items = content.items,
|
||||
onItemClick = onGalleryItemClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
3 -> ThreeItemLayout(
|
||||
items = content.items,
|
||||
onItemClick = onGalleryItemClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
else -> FourPlusItemLayout(
|
||||
items = content.items,
|
||||
showOverflow = showOverflow,
|
||||
overflowCount = overflowCount,
|
||||
onItemClick = onGalleryItemClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (content.showCaption) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val caption = if (LocalInspectionMode.current) {
|
||||
SpannedString(content.caption)
|
||||
} else {
|
||||
content.formattedCaption ?: SpannedString(content.caption)
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides ElementTheme.colors.textPrimary,
|
||||
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
|
||||
) {
|
||||
EditorStyledText(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 4.dp)
|
||||
.widthIn(min = 120.dp),
|
||||
text = caption,
|
||||
style = ElementRichTextEditorStyle.textStyle(),
|
||||
onLinkClickedListener = onLinkClick,
|
||||
onLinkLongClickedListener = onLinkLongClick,
|
||||
releaseOnDetach = false,
|
||||
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SingleItemLayout(
|
||||
item: GalleryItem,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: (() -> Unit)?,
|
||||
) {
|
||||
GalleryItemCell(
|
||||
item = item,
|
||||
isLast = false,
|
||||
remainingCount = 0,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = Modifier
|
||||
.width(GALLERY_WIDTH)
|
||||
.height(SINGLE_IMAGE_HEIGHT),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TwoItemLayout(
|
||||
items: ImmutableList<GalleryItem>,
|
||||
onItemClick: (Int) -> Unit,
|
||||
onLongClick: (() -> Unit)?,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.width(GALLERY_WIDTH),
|
||||
verticalArrangement = Arrangement.spacedBy(GRID_SPACING),
|
||||
) {
|
||||
items.forEachIndexed { index, item ->
|
||||
GalleryItemCell(
|
||||
item = item,
|
||||
isLast = false,
|
||||
remainingCount = 0,
|
||||
onClick = { onItemClick(index) },
|
||||
onLongClick = onLongClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(TWO_IMAGE_ROW_HEIGHT),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThreeItemLayout(
|
||||
items: ImmutableList<GalleryItem>,
|
||||
onItemClick: (Int) -> Unit,
|
||||
onLongClick: (() -> Unit)?,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.width(GALLERY_WIDTH),
|
||||
verticalArrangement = Arrangement.spacedBy(GRID_SPACING),
|
||||
) {
|
||||
GalleryItemCell(
|
||||
item = items[0],
|
||||
isLast = false,
|
||||
remainingCount = 0,
|
||||
onClick = { onItemClick(0) },
|
||||
onLongClick = onLongClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(SINGLE_IMAGE_HEIGHT),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(GRID_SPACING),
|
||||
) {
|
||||
for (it in 1..2) {
|
||||
GalleryItemCell(
|
||||
item = items[it],
|
||||
isLast = false,
|
||||
remainingCount = 0,
|
||||
onClick = { onItemClick(it) },
|
||||
onLongClick = onLongClick,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(TWO_IMAGE_ROW_HEIGHT),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FourPlusItemLayout(
|
||||
items: ImmutableList<GalleryItem>,
|
||||
showOverflow: Boolean,
|
||||
overflowCount: Int,
|
||||
onItemClick: (Int) -> Unit,
|
||||
onLongClick: (() -> Unit)?,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.width(GALLERY_WIDTH),
|
||||
verticalArrangement = Arrangement.spacedBy(GRID_SPACING),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(GRID_SPACING),
|
||||
) {
|
||||
for (it in 0..1) {
|
||||
GalleryItemCell(
|
||||
item = items[it],
|
||||
isLast = false,
|
||||
remainingCount = 0,
|
||||
onClick = { onItemClick(it) },
|
||||
onLongClick = onLongClick,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(TWO_IMAGE_ROW_HEIGHT),
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(GRID_SPACING),
|
||||
) {
|
||||
val bottomRowItems = if (showOverflow) 3 else minOf(items.size - 2, 3)
|
||||
val bottomRowHeight = if (bottomRowItems == 3) THREE_IMAGE_ROW_HEIGHT else TWO_IMAGE_ROW_HEIGHT
|
||||
for (i in 0 until bottomRowItems) {
|
||||
val itemIndex = 2 + i
|
||||
if (itemIndex < items.size) {
|
||||
val isOverflowItem = showOverflow && i == bottomRowItems - 1
|
||||
GalleryItemCell(
|
||||
item = items[itemIndex],
|
||||
isLast = isOverflowItem,
|
||||
remainingCount = if (isOverflowItem) overflowCount else 0,
|
||||
onClick = { onItemClick(itemIndex) },
|
||||
onLongClick = onLongClick,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(bottomRowHeight),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GalleryItemCell(
|
||||
item: GalleryItem,
|
||||
isLast: Boolean,
|
||||
remainingCount: Int,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: (() -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.blurHashBackground(item.blurhash, alpha = 0.9f)
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AsyncImage(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
model = item.thumbnailMediaRequestData,
|
||||
contentScale = ContentScale.Crop,
|
||||
alignment = Alignment.Center,
|
||||
contentDescription = item.filename,
|
||||
)
|
||||
|
||||
if (item.type == GalleryItem.Type.Video) {
|
||||
VideoOverlay(duration = item.duration)
|
||||
}
|
||||
|
||||
if (isLast && remainingCount > 0) {
|
||||
RemainingCountOverlay(count = remainingCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VideoOverlay(duration: kotlin.time.Duration) {
|
||||
val gradientColor = ElementTheme.colors.bgCanvasDefault
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(80.dp)
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(gradientColor.copy(alpha = 0f), gradientColor.copy(alpha = 1f))
|
||||
)
|
||||
)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.VideoCallSolid(),
|
||||
contentDescription = null,
|
||||
tint = ElementTheme.colors.textPrimary,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = duration.formatShort(),
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
style = ElementTheme.typography.fontBodySmMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RemainingCountOverlay(count: Int) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(ElementTheme.colors.bgSubtleTertiary.copy(alpha = 0.7f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "+$count",
|
||||
color = Color.White,
|
||||
style = ElementTheme.typography.fontHeadingSmMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemGalleryViewPreview(
|
||||
@PreviewParameter(TimelineItemGalleryContentProvider::class) content: TimelineItemGalleryContent,
|
||||
) = ElementPreview {
|
||||
TimelineItemGalleryView(
|
||||
content = content,
|
||||
onGalleryItemClick = {},
|
||||
onLongClick = {},
|
||||
onLinkClick = {},
|
||||
onLinkLongClick = {},
|
||||
onContentLayoutChange = {},
|
||||
)
|
||||
}
|
||||
+6
-6
@@ -15,13 +15,13 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.style.ResolvedTextDirection
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.core.data.tryOrNull
|
||||
import io.element.android.libraries.designsystem.text.roundToPx
|
||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||
import kotlin.math.max
|
||||
@@ -149,13 +149,13 @@ object ContentAvoidingLayout {
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
extraWidth: Dp = 0.dp,
|
||||
): ((TextLayoutResult) -> Unit) {
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
val extraWidthPx = extraWidth.roundToPx()
|
||||
return { textLayout: TextLayoutResult ->
|
||||
// We need to add the external extra width so it's not taken into account as 'free space'
|
||||
val lastLineWidth = when (layoutDirection) {
|
||||
LayoutDirection.Ltr -> textLayout.getLineRight(textLayout.lineCount - 1).roundToInt()
|
||||
LayoutDirection.Rtl -> textLayout.getLineLeft(textLayout.lineCount - 1).roundToInt()
|
||||
val textDirection = tryOrNull { textLayout.getParagraphDirection(0) }
|
||||
val lastLineWidth = when (textDirection) {
|
||||
ResolvedTextDirection.Rtl -> textLayout.getLineLeft(textLayout.lineCount - 1).roundToInt()
|
||||
else -> textLayout.getLineRight(textLayout.lineCount - 1).roundToInt()
|
||||
}
|
||||
val lastLineHeight = textLayout.getLineBottom(textLayout.lineCount - 1).roundToInt()
|
||||
onContentLayoutChange(
|
||||
|
||||
+16
-20
@@ -25,9 +25,10 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.hideFromAccessibility
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.testTag
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -53,27 +54,15 @@ import kotlinx.collections.immutable.ImmutableList
|
||||
@Composable
|
||||
fun TimelineItemReadReceiptView(
|
||||
state: ReadReceiptViewState,
|
||||
renderReadReceipts: Boolean,
|
||||
onReadReceiptsClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (state.receipts.isNotEmpty()) {
|
||||
if (renderReadReceipts) {
|
||||
ReadReceiptsRow(
|
||||
modifier = modifier.clearAndSetSemantics {
|
||||
hideFromAccessibility()
|
||||
}
|
||||
) {
|
||||
ReadReceiptsAvatars(
|
||||
receipts = state.receipts,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.clickable {
|
||||
onReadReceiptsClick()
|
||||
}
|
||||
.padding(2.dp)
|
||||
)
|
||||
}
|
||||
ReadReceiptsRow(modifier = modifier) {
|
||||
ReadReceiptsAvatars(
|
||||
receipts = state.receipts,
|
||||
onClick = onReadReceiptsClick,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
when (state.sendState) {
|
||||
@@ -132,7 +121,8 @@ private fun ReadReceiptsRow(
|
||||
@Composable
|
||||
private fun ReadReceiptsAvatars(
|
||||
receipts: ImmutableList<ReadReceiptData>,
|
||||
modifier: Modifier = Modifier
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val avatarSize = AvatarSize.TimelineReadReceipt.dp
|
||||
val avatarStrokeSize = 1.dp
|
||||
@@ -140,9 +130,16 @@ private fun ReadReceiptsAvatars(
|
||||
val receiptDescription = computeReceiptDescription(receipts)
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.clickable(
|
||||
onClick = onClick,
|
||||
onClickLabel = stringResource(id = CommonStrings.a11y_read_receipts_tap_to_show_all_android),
|
||||
)
|
||||
.padding(2.dp)
|
||||
.clearAndSetSemantics {
|
||||
testTag = TestTags.messageReadReceipts.value
|
||||
contentDescription = receiptDescription
|
||||
role = Role.Button
|
||||
},
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp - avatarStrokeSize),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -209,7 +206,6 @@ internal fun TimelineItemReadReceiptViewPreview(
|
||||
) = ElementPreview {
|
||||
TimelineItemReadReceiptView(
|
||||
state = state,
|
||||
renderReadReceipts = true,
|
||||
onReadReceiptsClick = {},
|
||||
)
|
||||
}
|
||||
|
||||
+8
-6
@@ -9,13 +9,13 @@
|
||||
package io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
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.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -44,7 +44,9 @@ internal fun ReadReceiptBottomSheet(
|
||||
) {
|
||||
val isVisible = state.selectedEvent != null
|
||||
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
val sheetState = rememberBottomSheetState(
|
||||
initialValue = SheetValue.Hidden,
|
||||
)
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
if (isVisible) {
|
||||
ModalBottomSheet(
|
||||
@@ -115,9 +117,9 @@ private fun ReadReceiptBottomSheetContent(
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun ReadReceiptBottomSheetPreview(@PreviewParameter(ReadReceiptBottomSheetStateProvider::class) state: ReadReceiptBottomSheetState) = ElementPreview {
|
||||
Column {
|
||||
ReadReceiptBottomSheetContent(
|
||||
internal fun ReadReceiptBottomSheetPreview(@PreviewParameter(ReadReceiptBottomSheetStateProvider::class) state: ReadReceiptBottomSheetState) {
|
||||
ElementPreview(fillMaxSize = true) {
|
||||
ReadReceiptBottomSheet(
|
||||
state = state,
|
||||
onUserDataClick = {},
|
||||
)
|
||||
|
||||
+8
-4
@@ -64,22 +64,24 @@ class TimelineItemsFactory(
|
||||
suspend fun replaceWith(
|
||||
timelineItems: List<MatrixTimelineItem>,
|
||||
roomMembers: List<RoomMember>,
|
||||
renderReadReceipts: Boolean,
|
||||
) = withContext(dispatchers.computation) {
|
||||
lock.withLock {
|
||||
diffCacheUpdater.updateWith(timelineItems)
|
||||
buildAndEmitTimelineItemStates(timelineItems, roomMembers)
|
||||
buildAndEmitTimelineItemStates(timelineItems, roomMembers, renderReadReceipts)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun buildAndEmitTimelineItemStates(
|
||||
timelineItems: List<MatrixTimelineItem>,
|
||||
roomMembers: List<RoomMember>,
|
||||
renderReadReceipts: Boolean,
|
||||
) {
|
||||
val newTimelineItemStates = ArrayList<TimelineItem>()
|
||||
for (index in diffCache.indices().reversed()) {
|
||||
val cacheItem = diffCache.get(index)
|
||||
if (cacheItem == null) {
|
||||
buildAndCacheItem(timelineItems, index, roomMembers)?.also { timelineItemState ->
|
||||
buildAndCacheItem(timelineItems, index, roomMembers, renderReadReceipts)?.also { timelineItemState ->
|
||||
newTimelineItemStates.add(timelineItemState)
|
||||
}
|
||||
} else {
|
||||
@@ -87,7 +89,8 @@ class TimelineItemsFactory(
|
||||
eventItemFactory.update(
|
||||
timelineItem = cacheItem,
|
||||
receivedMatrixTimelineItem = timelineItems[index] as MatrixTimelineItem.Event,
|
||||
roomMembers = roomMembers
|
||||
roomMembers = roomMembers,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
)
|
||||
} else {
|
||||
cacheItem
|
||||
@@ -103,10 +106,11 @@ class TimelineItemsFactory(
|
||||
timelineItems: List<MatrixTimelineItem>,
|
||||
index: Int,
|
||||
roomMembers: List<RoomMember>,
|
||||
renderReadReceipts: Boolean,
|
||||
): TimelineItem? {
|
||||
val timelineItem =
|
||||
when (val currentTimelineItem = timelineItems[index]) {
|
||||
is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems, roomMembers)
|
||||
is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems, roomMembers, renderReadReceipts)
|
||||
is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem)
|
||||
MatrixTimelineItem.Other -> null
|
||||
}
|
||||
|
||||
+144
@@ -14,10 +14,14 @@ import androidx.core.text.toSpannable
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.features.location.api.Location
|
||||
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
|
||||
import io.element.android.features.messages.impl.timeline.model.event.AttachmentItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.GalleryItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent
|
||||
@@ -35,6 +39,8 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryItemType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||
@@ -263,6 +269,144 @@ class TimelineItemContentMessageFactory(
|
||||
isEdited = content.isEdited,
|
||||
)
|
||||
}
|
||||
is GalleryMessageType -> {
|
||||
val dom = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
|
||||
val formattedCaption = dom?.let(::parseHtml)
|
||||
?: messageType.body.withLinks()
|
||||
val galleryItems = messageType.items.mapNotNull { item ->
|
||||
when (item) {
|
||||
is GalleryItemType.Image -> {
|
||||
GalleryItem(
|
||||
filename = item.content.filename,
|
||||
mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream,
|
||||
mediaSource = item.content.source,
|
||||
type = GalleryItem.Type.Image,
|
||||
thumbnailSource = item.content.info?.thumbnailSource,
|
||||
width = item.content.info?.width?.toInt(),
|
||||
height = item.content.info?.height?.toInt(),
|
||||
thumbnailWidth = item.content.info?.thumbnailInfo?.width?.toInt(),
|
||||
thumbnailHeight = item.content.info?.thumbnailInfo?.height?.toInt(),
|
||||
blurhash = item.content.info?.blurhash,
|
||||
)
|
||||
}
|
||||
is GalleryItemType.Video -> {
|
||||
GalleryItem(
|
||||
filename = item.content.filename,
|
||||
mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream,
|
||||
mediaSource = item.content.source,
|
||||
type = GalleryItem.Type.Video,
|
||||
thumbnailSource = item.content.info?.thumbnailSource,
|
||||
width = item.content.info?.width?.toInt(),
|
||||
height = item.content.info?.height?.toInt(),
|
||||
thumbnailWidth = item.content.info?.thumbnailInfo?.width?.toInt(),
|
||||
thumbnailHeight = item.content.info?.thumbnailInfo?.height?.toInt(),
|
||||
blurhash = item.content.info?.blurhash,
|
||||
duration = item.content.info?.duration ?: Duration.ZERO,
|
||||
)
|
||||
}
|
||||
is GalleryItemType.Audio -> {
|
||||
GalleryItem(
|
||||
filename = item.content.filename,
|
||||
mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream,
|
||||
mediaSource = item.content.source,
|
||||
type = GalleryItem.Type.Audio,
|
||||
thumbnailSource = null,
|
||||
width = null,
|
||||
height = null,
|
||||
thumbnailWidth = null,
|
||||
thumbnailHeight = null,
|
||||
blurhash = null,
|
||||
duration = item.content.info?.duration ?: Duration.ZERO,
|
||||
)
|
||||
}
|
||||
is GalleryItemType.File -> {
|
||||
GalleryItem(
|
||||
filename = item.content.filename,
|
||||
mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream,
|
||||
mediaSource = item.content.source,
|
||||
type = GalleryItem.Type.File,
|
||||
thumbnailSource = item.content.info?.thumbnailSource,
|
||||
width = null,
|
||||
height = null,
|
||||
thumbnailWidth = null,
|
||||
thumbnailHeight = null,
|
||||
blurhash = null,
|
||||
)
|
||||
}
|
||||
is GalleryItemType.Other -> null
|
||||
}
|
||||
}
|
||||
val hasPreviews = galleryItems.any { it.thumbnailSource != null }
|
||||
val isOnlyVisualMedia = galleryItems.all { item ->
|
||||
item.type.isVisualMedia()
|
||||
}
|
||||
if (isOnlyVisualMedia && hasPreviews) {
|
||||
TimelineItemGalleryContent(
|
||||
body = messageType.body,
|
||||
caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() },
|
||||
formattedCaption = formattedCaption,
|
||||
isEdited = content.isEdited,
|
||||
items = galleryItems.toImmutableList(),
|
||||
)
|
||||
} else {
|
||||
val attachments = messageType.items.mapNotNull { item ->
|
||||
when (item) {
|
||||
is GalleryItemType.File -> {
|
||||
AttachmentItem(
|
||||
filename = item.content.filename,
|
||||
mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream,
|
||||
mediaSource = item.content.source,
|
||||
thumbnailSource = item.content.info?.thumbnailSource,
|
||||
fileSize = item.content.info?.size,
|
||||
formattedFileSize = fileSizeFormatter.format(item.content.info?.size ?: 0L),
|
||||
fileExtension = fileExtensionExtractor.extractFromName(item.content.filename),
|
||||
)
|
||||
}
|
||||
is GalleryItemType.Image -> {
|
||||
AttachmentItem(
|
||||
filename = item.content.filename,
|
||||
mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream,
|
||||
mediaSource = item.content.source,
|
||||
thumbnailSource = item.content.info?.thumbnailSource,
|
||||
fileSize = item.content.info?.size,
|
||||
formattedFileSize = fileSizeFormatter.format(item.content.info?.size ?: 0L),
|
||||
fileExtension = fileExtensionExtractor.extractFromName(item.content.filename),
|
||||
)
|
||||
}
|
||||
is GalleryItemType.Video -> {
|
||||
AttachmentItem(
|
||||
filename = item.content.filename,
|
||||
mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream,
|
||||
mediaSource = item.content.source,
|
||||
thumbnailSource = item.content.info?.thumbnailSource,
|
||||
fileSize = item.content.info?.size,
|
||||
formattedFileSize = fileSizeFormatter.format(item.content.info?.size ?: 0L),
|
||||
fileExtension = fileExtensionExtractor.extractFromName(item.content.filename),
|
||||
)
|
||||
}
|
||||
is GalleryItemType.Audio -> {
|
||||
AttachmentItem(
|
||||
filename = item.content.filename,
|
||||
mimeType = item.content.info?.mimetype ?: MimeTypes.OctetStream,
|
||||
mediaSource = item.content.source,
|
||||
thumbnailSource = null,
|
||||
fileSize = item.content.info?.size,
|
||||
formattedFileSize = fileSizeFormatter.format(item.content.info?.size ?: 0L),
|
||||
fileExtension = fileExtensionExtractor.extractFromName(item.content.filename),
|
||||
)
|
||||
}
|
||||
is GalleryItemType.Other -> null
|
||||
}
|
||||
}
|
||||
TimelineItemAttachmentsContent(
|
||||
body = messageType.body,
|
||||
caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() },
|
||||
formattedCaption = formattedCaption,
|
||||
isEdited = content.isEdited,
|
||||
attachments = attachments.toImmutableList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
is OtherMessageType -> {
|
||||
val body = messageType.body.trimEnd()
|
||||
TimelineItemTextContent(
|
||||
|
||||
+6
-3
@@ -57,6 +57,7 @@ class TimelineItemEventFactory(
|
||||
index: Int,
|
||||
timelineItems: List<MatrixTimelineItem>,
|
||||
roomMembers: List<RoomMember>,
|
||||
renderReadReceipts: Boolean,
|
||||
): TimelineItem.Event {
|
||||
val currentSender = currentTimelineItem.event.sender
|
||||
val groupPosition =
|
||||
@@ -116,7 +117,7 @@ class TimelineItemEventFactory(
|
||||
sentDate = sentDate,
|
||||
groupPosition = groupPosition,
|
||||
reactionsState = currentTimelineItem.computeReactionsState(),
|
||||
readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers),
|
||||
readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers, renderReadReceipts),
|
||||
localSendState = currentTimelineItem.event.localSendState,
|
||||
inReplyTo = currentTimelineItem.event.inReplyTo()?.map(permalinkParser = permalinkParser),
|
||||
threadInfo = mappedThreadInfo,
|
||||
@@ -133,9 +134,10 @@ class TimelineItemEventFactory(
|
||||
timelineItem: TimelineItem.Event,
|
||||
receivedMatrixTimelineItem: MatrixTimelineItem.Event,
|
||||
roomMembers: List<RoomMember>,
|
||||
renderReadReceipts: Boolean,
|
||||
): TimelineItem.Event {
|
||||
return timelineItem.copy(
|
||||
readReceiptState = receivedMatrixTimelineItem.computeReadReceiptState(roomMembers)
|
||||
readReceiptState = receivedMatrixTimelineItem.computeReadReceiptState(roomMembers, renderReadReceipts)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -180,8 +182,9 @@ class TimelineItemEventFactory(
|
||||
|
||||
private fun MatrixTimelineItem.Event.computeReadReceiptState(
|
||||
roomMembers: List<RoomMember>,
|
||||
renderReadReceipts: Boolean,
|
||||
): TimelineItemReadReceipts {
|
||||
if (!config.computeReadReceipts) {
|
||||
if (!config.computeReadReceipts || !renderReadReceipts) {
|
||||
return TimelineItemReadReceipts(receipts = persistentListOf())
|
||||
}
|
||||
return TimelineItemReadReceipts(
|
||||
|
||||
+4
@@ -9,9 +9,11 @@
|
||||
package io.element.android.features.messages.impl.timeline.groups
|
||||
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
|
||||
@@ -52,6 +54,8 @@ internal fun TimelineItem.Event.canBeGrouped(): Boolean {
|
||||
is TimelineItemTextBasedContent,
|
||||
is TimelineItemEncryptedContent,
|
||||
is TimelineItemImageContent,
|
||||
is TimelineItemGalleryContent,
|
||||
is TimelineItemAttachmentsContent,
|
||||
is TimelineItemStickerContent,
|
||||
is TimelineItemFileContent,
|
||||
is TimelineItemVideoContent,
|
||||
|
||||
+7
-4
@@ -8,12 +8,15 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
/**
|
||||
* Model if there is a new event in the timeline and if it is from me or from other.
|
||||
* This can be used to scroll to the bottom of the list when a new event is added.
|
||||
*/
|
||||
enum class NewEventState {
|
||||
None,
|
||||
FromMe,
|
||||
FromOther
|
||||
@Immutable
|
||||
sealed interface NewEventState {
|
||||
data object None : NewEventState
|
||||
data object FromMe : NewEventState
|
||||
data object FromOther : NewEventState
|
||||
}
|
||||
|
||||
+4
@@ -10,7 +10,9 @@ package io.element.android.features.messages.impl.timeline.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.messages.impl.timeline.components.MessageShieldData
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
||||
@@ -124,6 +126,8 @@ sealed interface TimelineItem {
|
||||
is TimelineItemStickerContent -> content.formattedCaption == null && content.caption == null
|
||||
is TimelineItemImageContent -> content.formattedCaption == null && content.caption == null
|
||||
is TimelineItemVideoContent -> content.formattedCaption == null && content.caption == null
|
||||
is TimelineItemGalleryContent -> false
|
||||
is TimelineItemAttachmentsContent -> false
|
||||
else -> true
|
||||
}
|
||||
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.model.event
|
||||
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class TimelineItemAttachmentsContent(
|
||||
val body: String,
|
||||
val caption: String?,
|
||||
val formattedCaption: CharSequence?,
|
||||
override val isEdited: Boolean,
|
||||
val attachments: ImmutableList<AttachmentItem>,
|
||||
) : TimelineItemEventContent, TimelineItemEventMutableContent {
|
||||
override val type: String = "TimelineItemAttachmentsContent"
|
||||
|
||||
val showCaption = caption != null
|
||||
|
||||
val hasPreviews = attachments.any { it.thumbnailSource != null }
|
||||
}
|
||||
|
||||
data class AttachmentItem(
|
||||
val filename: String,
|
||||
val mimeType: String,
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val fileSize: Long?,
|
||||
val formattedFileSize: String,
|
||||
val fileExtension: String,
|
||||
)
|
||||
+18
-2
@@ -56,7 +56,9 @@ fun TimelineItemEventContent.canBeForwarded(): Boolean =
|
||||
is TimelineItemAudioContent,
|
||||
is TimelineItemVideoContent,
|
||||
is TimelineItemLocationContent,
|
||||
is TimelineItemVoiceContent -> true
|
||||
is TimelineItemVoiceContent,
|
||||
is TimelineItemGalleryContent,
|
||||
is TimelineItemAttachmentsContent -> true
|
||||
// Stickers can't be forwarded (yet) so we don't show the option
|
||||
// See https://github.com/element-hq/element-x-android/issues/2161
|
||||
is TimelineItemStickerContent -> false
|
||||
@@ -78,7 +80,9 @@ fun TimelineItemEventContent.canReact(): Boolean =
|
||||
is TimelineItemLocationContent,
|
||||
is TimelineItemPollContent,
|
||||
is TimelineItemVoiceContent,
|
||||
is TimelineItemVideoContent -> true
|
||||
is TimelineItemVideoContent,
|
||||
is TimelineItemGalleryContent,
|
||||
is TimelineItemAttachmentsContent -> true
|
||||
is TimelineItemStateContent,
|
||||
is TimelineItemRedactedContent,
|
||||
is TimelineItemLegacyCallInviteContent,
|
||||
@@ -99,6 +103,18 @@ fun TimelineItemEventContent.isEdited(): Boolean = when (this) {
|
||||
*/
|
||||
fun TimelineItemEventContent.isRedacted(): Boolean = this is TimelineItemRedactedContent
|
||||
|
||||
/**
|
||||
* Returns the caption text for content types that support captions.
|
||||
* Gallery and attachments content types have captions but don't implement
|
||||
* [TimelineItemEventContentWithAttachment].
|
||||
*/
|
||||
fun TimelineItemEventContent.captionOrNull(): String? = when (this) {
|
||||
is TimelineItemEventContentWithAttachment -> caption
|
||||
is TimelineItemGalleryContent -> caption
|
||||
is TimelineItemAttachmentsContent -> caption
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun TimelineItemEventContentWithAttachment.duration(): Duration? {
|
||||
return when (this) {
|
||||
is TimelineItemAudioContent -> duration
|
||||
|
||||
+38
@@ -13,7 +13,9 @@ import android.text.style.StyleSpan
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import androidx.core.text.buildSpannedString
|
||||
import androidx.core.text.inSpans
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import org.jsoup.nodes.Document
|
||||
|
||||
class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEventContent> {
|
||||
@@ -111,3 +113,39 @@ fun aTimelineItemStateEventContent(
|
||||
) = TimelineItemStateEventContent(
|
||||
body = body,
|
||||
)
|
||||
|
||||
fun aTimelineItemAttachmentsContent(
|
||||
body: String = "Attachments",
|
||||
caption: String? = null,
|
||||
attachments: List<AttachmentItem> = listOf(
|
||||
anAttachmentItem(filename = "document.pdf", fileExtension = "pdf"),
|
||||
anAttachmentItem(filename = "recording.mp3", fileExtension = "mp3", fileSize = 4_500_000L, formattedFileSize = "4.5MB"),
|
||||
),
|
||||
) = TimelineItemAttachmentsContent(
|
||||
body = body,
|
||||
caption = caption,
|
||||
formattedCaption = null,
|
||||
isEdited = false,
|
||||
attachments = attachments.toImmutableList(),
|
||||
)
|
||||
|
||||
fun anAttachmentItem(
|
||||
filename: String = "file.pdf",
|
||||
fileExtension: String = "pdf",
|
||||
fileSize: Long? = 1_000_000L,
|
||||
formattedFileSize: String = "1MB",
|
||||
mimeType: String? = null,
|
||||
thumbnailSource: MediaSource? = null,
|
||||
hasThumbnail: Boolean = false,
|
||||
) = AttachmentItem(
|
||||
filename = filename,
|
||||
mimeType = mimeType ?: when {
|
||||
hasThumbnail -> "image/jpeg"
|
||||
else -> "application/$fileExtension"
|
||||
},
|
||||
mediaSource = MediaSource(url = "", json = ""),
|
||||
thumbnailSource = thumbnailSource ?: if (hasThumbnail) MediaSource(url = "", json = "") else null,
|
||||
fileSize = fileSize,
|
||||
formattedFileSize = formattedFileSize,
|
||||
fileExtension = fileExtension,
|
||||
)
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.model.event
|
||||
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlin.time.Duration
|
||||
|
||||
data class TimelineItemGalleryContent(
|
||||
val body: String,
|
||||
val caption: String?,
|
||||
val formattedCaption: CharSequence?,
|
||||
override val isEdited: Boolean,
|
||||
val items: ImmutableList<GalleryItem>,
|
||||
) : TimelineItemEventContent, TimelineItemEventMutableContent {
|
||||
override val type: String = "TimelineItemGalleryContent"
|
||||
|
||||
val showCaption = caption != null
|
||||
}
|
||||
|
||||
data class GalleryItem(
|
||||
val filename: String,
|
||||
val mimeType: String,
|
||||
val mediaSource: MediaSource,
|
||||
val type: Type,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val width: Int?,
|
||||
val height: Int?,
|
||||
val thumbnailWidth: Int?,
|
||||
val thumbnailHeight: Int?,
|
||||
val blurhash: String?,
|
||||
val duration: Duration = Duration.ZERO,
|
||||
) {
|
||||
enum class Type {
|
||||
Image,
|
||||
Video,
|
||||
Audio,
|
||||
File;
|
||||
|
||||
fun isVisualMedia() = this in setOf(Image, Video)
|
||||
}
|
||||
|
||||
val thumbnailMediaRequestData: MediaRequestData by lazy {
|
||||
MediaRequestData(
|
||||
source = thumbnailSource ?: mediaSource,
|
||||
kind = MediaRequestData.Kind.Thumbnail(
|
||||
width = thumbnailWidth?.toLong() ?: io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH,
|
||||
height = thumbnailHeight?.toLong() ?: io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val aspectRatio: Float? by lazy {
|
||||
if (width != null && height != null && height > 0) {
|
||||
width.toFloat() / height.toFloat()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -9,10 +9,12 @@
|
||||
package io.element.android.features.messages.impl.timeline.protection
|
||||
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
|
||||
@@ -40,6 +42,8 @@ fun TimelineItem.mustBeProtected(): Boolean {
|
||||
} else {
|
||||
when (content) {
|
||||
is TimelineItemImageContent,
|
||||
is TimelineItemGalleryContent,
|
||||
is TimelineItemAttachmentsContent,
|
||||
is TimelineItemVideoContent,
|
||||
is TimelineItemStickerContent -> true
|
||||
is TimelineItemAudioContent,
|
||||
|
||||
+12
@@ -11,10 +11,12 @@ package io.element.android.features.messages.impl.utils.messagesummary
|
||||
import android.content.Context
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemGalleryContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
|
||||
@@ -56,6 +58,16 @@ class DefaultMessageSummaryFormatter(
|
||||
is TimelineItemVideoContent -> context.getString(CommonStrings.common_video)
|
||||
is TimelineItemFileContent -> context.getString(CommonStrings.common_file)
|
||||
is TimelineItemAudioContent -> context.getString(CommonStrings.common_audio)
|
||||
is TimelineItemGalleryContent -> context.getString(CommonStrings.common_gallery)
|
||||
is TimelineItemAttachmentsContent -> {
|
||||
val count = content.attachments.size
|
||||
val extensions = content.attachments.take(3).joinToString { it.fileExtension }
|
||||
if (count <= 3) {
|
||||
extensions
|
||||
} else {
|
||||
"$extensions +${count - 3}"
|
||||
}
|
||||
}
|
||||
is TimelineItemLegacyCallInviteContent -> context.getString(CommonStrings.common_unsupported_call)
|
||||
is TimelineItemRtcNotificationContent -> when (content.state) {
|
||||
is RtcNotificationState.Declined -> {
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
<string name="emoji_picker_category_symbols">"Сімвалы"</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Не атрымалася апрацаваць медыяфайл для загрузкі, паспрабуйце яшчэ раз."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Не атрымалася загрузіць медыяфайлы, паспрабуйце яшчэ раз."</string>
|
||||
<string name="screen_media_upload_preview_processing">"Апрацоўка…"</string>
|
||||
<string name="screen_media_upload_preview_title">"Дадаць медыя"</string>
|
||||
<string name="screen_report_content_block_user">"Заблакіраваць карыстальніка"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Адзначце, ці хочаце вы схаваць усе бягучыя і будучыя паведамленні ад гэтага карыстальніка"</string>
|
||||
<string name="screen_report_content_explanation">"Гэтае паведамленне будзе перададзена адміністратару вашага хатняга сервера. Яны не змогуць прачытаць зашыфраваныя паведамленні."</string>
|
||||
@@ -25,7 +27,7 @@
|
||||
<string name="screen_room_attachment_source_camera_video">"Запісаць відэа"</string>
|
||||
<string name="screen_room_attachment_source_files">"Далучэнне"</string>
|
||||
<string name="screen_room_attachment_source_gallery">"Бібліятэка фота & відэа"</string>
|
||||
<string name="screen_room_attachment_source_location">"Месцазнаходжанне"</string>
|
||||
<string name="screen_room_attachment_source_location">"Абагуліць месцазнаходжанне"</string>
|
||||
<string name="screen_room_attachment_source_poll">"Апытанне"</string>
|
||||
<string name="screen_room_attachment_text_formatting">"Фармаціраванне тэксту"</string>
|
||||
<string name="screen_room_encrypted_history_banner">"Гісторыя паведамленняў зараз недаступна."</string>
|
||||
|
||||
@@ -16,6 +16,18 @@
|
||||
<string name="emoji_picker_category_places">"Reisimine ja kohad"</string>
|
||||
<string name="emoji_picker_category_recent">"Hiljutised emojid"</string>
|
||||
<string name="emoji_picker_category_symbols">"Sümbolid"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"Pööra pilti ümber rõhttelje"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Pilt on pööratud ümber rõhttelje"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Pildi algne variant"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically">"Pööra pilti ümber püsttelje"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Pilt on pööratud ümber püsttelje"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Pildi algne variant"</string>
|
||||
<string name="screen_image_edition_a11y_rotate_to_the_left">"Pööra pilti vasakule"</string>
|
||||
<plurals name="screen_image_edition_a11y_rotation_state">
|
||||
<item quantity="one">"%1$d kraad"</item>
|
||||
<item quantity="other">"%1$d kraadi"</item>
|
||||
</plurals>
|
||||
<string name="screen_image_edition_title">"Muuda fotot"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Selgitused ja alapealkirjad ei pruugi olla nähtavad vanemate rakenduste kasutajatele."</string>
|
||||
<string name="screen_media_upload_preview_change_video_quality_prompt">"Klõpsa üleslaaditava video kvaliteedi muutmiseks"</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Faili üleslaadimine ei õnnestunud."</string>
|
||||
@@ -26,6 +38,7 @@
|
||||
<string name="screen_media_upload_preview_item_count">"Objekt %1$d/%2$d"</string>
|
||||
<string name="screen_media_upload_preview_optimize_image_quality_title">"Optimeeri pildikvaliteeti"</string>
|
||||
<string name="screen_media_upload_preview_processing">"Töötlen…"</string>
|
||||
<string name="screen_media_upload_preview_title">"Lisa meediumi"</string>
|
||||
<string name="screen_report_content_block_user">"Blokeeri kasutaja"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Vali see eelistus, kui sa soovid peita selle kasutaja kõik senised ja tulevased sõnumid"</string>
|
||||
<string name="screen_report_content_explanation">"Teade selle sõnumi kohta edastatakse sinu koduserveri haldajale. Haldajal ei ole võimalik lugeda krüptitud sõnumite sisu."</string>
|
||||
|
||||
@@ -16,6 +16,18 @@
|
||||
<string name="emoji_picker_category_places">"Matkustaminen ja paikat"</string>
|
||||
<string name="emoji_picker_category_recent">"Viimeaikaiset emojit"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symbolit"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"Käännä kuva vaakasuunnassa"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Käännetty vaakasuunnassa"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Alkuperäinen"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically">"Käännä kuva pystysuunnassa"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Käännetty pystysuunnassa"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Alkuperäinen"</string>
|
||||
<string name="screen_image_edition_a11y_rotate_to_the_left">"Kierrä kuvaa vasemmalle"</string>
|
||||
<plurals name="screen_image_edition_a11y_rotation_state">
|
||||
<item quantity="one">"%1$d aste"</item>
|
||||
<item quantity="other">"%1$d astetta"</item>
|
||||
</plurals>
|
||||
<string name="screen_image_edition_title">"Muokkaa kuvaa"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Kuvatekstit eivät välttämättä näy ihmisille, jotka käyttävät vanhempia sovelluksia."</string>
|
||||
<string name="screen_media_upload_preview_change_video_quality_prompt">"Napauta muuttaaksesi videon lähetyslaatua"</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Tiedostoa ei voitu lähettää."</string>
|
||||
@@ -26,6 +38,7 @@
|
||||
<string name="screen_media_upload_preview_item_count">"Kohde %1$d / %2$d"</string>
|
||||
<string name="screen_media_upload_preview_optimize_image_quality_title">"Optimoi kuvanlaatu"</string>
|
||||
<string name="screen_media_upload_preview_processing">"Käsitellään…"</string>
|
||||
<string name="screen_media_upload_preview_title">"Lisää mediaa"</string>
|
||||
<string name="screen_report_content_block_user">"Estä käyttäjä"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Valitse tämä, jos haluat piilottaa kaikki nykyiset ja tulevat viestit tältä käyttäjältä"</string>
|
||||
<string name="screen_report_content_explanation">"Tämä viesti ilmoitetaan kotipalvelimesi ylläpitäjälle. Ylläpitäjä ei pysty lukemaan salattuja viestejä."</string>
|
||||
|
||||
@@ -16,6 +16,18 @@
|
||||
<string name="emoji_picker_category_places">"Voyages & lieux"</string>
|
||||
<string name="emoji_picker_category_recent">"Emojis récents"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symboles"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"Retourner l’image horizontalement"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Retournée horizontalement"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Originale"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically">"Retourner l’image verticalement"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Retournée verticalement"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Originale"</string>
|
||||
<string name="screen_image_edition_a11y_rotate_to_the_left">"Pivoter l’image vers la gauche"</string>
|
||||
<plurals name="screen_image_edition_a11y_rotation_state">
|
||||
<item quantity="one">"%1$d degré"</item>
|
||||
<item quantity="other">"%1$d degrés"</item>
|
||||
</plurals>
|
||||
<string name="screen_image_edition_title">"Modifier la photo"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Les légendes peuvent ne pas être visibles pour les utilisateurs d’anciennes applications."</string>
|
||||
<string name="screen_media_upload_preview_change_video_quality_prompt">"Cliquez pour modifier la qualité d’envoi de la vidéo"</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Le fichier n’a pas pu être envoyé."</string>
|
||||
@@ -26,6 +38,7 @@
|
||||
<string name="screen_media_upload_preview_item_count">"Élément %1$d sur %2$d"</string>
|
||||
<string name="screen_media_upload_preview_optimize_image_quality_title">"Optimiser la qualité de l’image"</string>
|
||||
<string name="screen_media_upload_preview_processing">"Traitement en cours…"</string>
|
||||
<string name="screen_media_upload_preview_title">"Ajouter un média"</string>
|
||||
<string name="screen_report_content_block_user">"Bloquer l’utilisateur"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Cochez si vous souhaitez masquer tous les messages actuels et futurs de cet utilisateur."</string>
|
||||
<string name="screen_report_content_explanation">"Ce message sera signalé à l’administrateur de votre serveur d’accueil. Il ne pourra lire aucun message chiffré."</string>
|
||||
|
||||
@@ -16,6 +16,18 @@
|
||||
<string name="emoji_picker_category_places">"Utazás és helyek"</string>
|
||||
<string name="emoji_picker_category_recent">"Legutóbbi emodzsik"</string>
|
||||
<string name="emoji_picker_category_symbols">"Szimbólumok"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"Kép vízszintes tükrözése"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Vízszintesen tükrözve"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Eredeti"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically">"Kép függőleges tükrözése"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Függőlegesen tükrözve"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Eredeti"</string>
|
||||
<string name="screen_image_edition_a11y_rotate_to_the_left">"Kép elforgatása balra"</string>
|
||||
<plurals name="screen_image_edition_a11y_rotation_state">
|
||||
<item quantity="one">"%1$d fok"</item>
|
||||
<item quantity="other">"%1$d fok"</item>
|
||||
</plurals>
|
||||
<string name="screen_image_edition_title">"Fotó szerkesztése"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Előfordulhat, hogy a feliratok nem láthatók a régebbi alkalmazásokat használók számára."</string>
|
||||
<string name="screen_media_upload_preview_change_video_quality_prompt">"Koppintson a feltöltött videók minőségének módosításához"</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"A fájl nem tölthető fel."</string>
|
||||
@@ -26,6 +38,7 @@
|
||||
<string name="screen_media_upload_preview_item_count">"%1$d. elem / %2$d"</string>
|
||||
<string name="screen_media_upload_preview_optimize_image_quality_title">"Képminőség optimalizációja"</string>
|
||||
<string name="screen_media_upload_preview_processing">"Feldolgozás…"</string>
|
||||
<string name="screen_media_upload_preview_title">"Média hozzáadása"</string>
|
||||
<string name="screen_report_content_block_user">"Felhasználó letiltása"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Jelölje be, ha el akarja rejteni az összes jelenlegi és jövőbeli üzenetet ettől a felhasználótól"</string>
|
||||
<string name="screen_report_content_explanation">"Ez az üzenet jelentve lesz a Matrix-kiszolgáló adminisztrátorának. Nem fogja tudni elolvasni a titkosított üzeneteket."</string>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Gagal mengunggah media, silakan coba lagi."</string>
|
||||
<string name="screen_media_upload_preview_error_too_large_message">"Ukuran file maksimum yang diizinkan adalah%1$s ."</string>
|
||||
<string name="screen_media_upload_preview_error_too_large_title">"Ukuran file terlalu besar untuk diunggah."</string>
|
||||
<string name="screen_media_upload_preview_item_count">"Item %1$d dari %2$d"</string>
|
||||
<string name="screen_media_upload_preview_optimize_image_quality_title">"Optimalkan kualitas gambar"</string>
|
||||
<string name="screen_media_upload_preview_processing">"Memproses…"</string>
|
||||
<string name="screen_report_content_block_user">"Blokir pengguna"</string>
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
<string name="emoji_picker_category_places">"旅・場所"</string>
|
||||
<string name="emoji_picker_category_recent">"最近使用"</string>
|
||||
<string name="emoji_picker_category_symbols">"記号"</string>
|
||||
<string name="screen_image_edition_a11y_rotate_to_the_left">"画像を左に回転"</string>
|
||||
<plurals name="screen_image_edition_a11y_rotation_state">
|
||||
<item quantity="other">"%1$d°"</item>
|
||||
</plurals>
|
||||
<string name="screen_image_edition_title">"写真を編集"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"古いアプリケーションを使用しているユーザーはキャプションを見られない可能性があります。"</string>
|
||||
<string name="screen_media_upload_preview_change_video_quality_prompt">"動画のアップロード画質を変更するにはタップしてください"</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"ファイルをアップロードに失敗しました。"</string>
|
||||
@@ -34,7 +39,7 @@
|
||||
<string name="screen_room_attachment_source_camera">"カメラ"</string>
|
||||
<string name="screen_room_attachment_source_camera_photo">"写真を撮影"</string>
|
||||
<string name="screen_room_attachment_source_camera_video">"動画を撮影"</string>
|
||||
<string name="screen_room_attachment_source_files">"添付ファイル"</string>
|
||||
<string name="screen_room_attachment_source_files">"ファイルを添付"</string>
|
||||
<string name="screen_room_attachment_source_gallery">"アルバムの写真と動画"</string>
|
||||
<string name="screen_room_attachment_source_location">"場所を共有"</string>
|
||||
<string name="screen_room_attachment_source_poll">"投票"</string>
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
<string name="emoji_picker_category_places">"여행 & 장소"</string>
|
||||
<string name="emoji_picker_category_recent">"최근 이모지"</string>
|
||||
<string name="emoji_picker_category_symbols">"상징"</string>
|
||||
<string name="screen_image_edition_a11y_rotate_to_the_left">"이미지를 왼쪽으로 회전"</string>
|
||||
<plurals name="screen_image_edition_a11y_rotation_state">
|
||||
<item quantity="other">"%1$d도"</item>
|
||||
</plurals>
|
||||
<string name="screen_image_edition_title">"사진 편집"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"캡션은 오래된 앱을 사용하는 사용자에게 표시되지 않을 수 있습니다."</string>
|
||||
<string name="screen_media_upload_preview_change_video_quality_prompt">"비디오 업로드 품질을 변경하려면 탭하세요"</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"파일을 업로드할 수 없습니다."</string>
|
||||
@@ -26,6 +31,7 @@
|
||||
<string name="screen_media_upload_preview_item_count">"전체 %2$d개 중 %1$d번째 파일"</string>
|
||||
<string name="screen_media_upload_preview_optimize_image_quality_title">"이미지 품질 최적화"</string>
|
||||
<string name="screen_media_upload_preview_processing">"처리 중…"</string>
|
||||
<string name="screen_media_upload_preview_title">"미디어 추가"</string>
|
||||
<string name="screen_report_content_block_user">"사용자 차단하기"</string>
|
||||
<string name="screen_report_content_block_user_hint">"이 사용자의 현재 및 향후 모든 메시지를 숨기려면 확인하세요."</string>
|
||||
<string name="screen_report_content_explanation">"이 메시지는 홈서버의 관리자에게 보고되었습니다. 암호화된 메시지는 읽을 수 없습니다."</string>
|
||||
|
||||
@@ -16,6 +16,19 @@
|
||||
<string name="emoji_picker_category_places">"Podróż i miejsca"</string>
|
||||
<string name="emoji_picker_category_recent">"Ostatnie emoji"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symbole"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"Odwróć obraz w poziomie"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Odwrócono w poziomie"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Oryginalny"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically">"Odwróć obraz w pionie"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Odwrócono w pionie"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Oryginalny"</string>
|
||||
<string name="screen_image_edition_a11y_rotate_to_the_left">"Obróć obraz w lewo"</string>
|
||||
<plurals name="screen_image_edition_a11y_rotation_state">
|
||||
<item quantity="one">"%1$d stopień"</item>
|
||||
<item quantity="few">"%1$d stopnie"</item>
|
||||
<item quantity="many">"%1$d stopni"</item>
|
||||
</plurals>
|
||||
<string name="screen_image_edition_title">"Edytuj zdjęcie"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Opis może być niedostępny dla osób korzystających ze starszej wersji aplikacji."</string>
|
||||
<string name="screen_media_upload_preview_change_video_quality_prompt">"Dotknij, aby zmienić jakość przesyłania wideo."</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Nie udało się przesłać pliku."</string>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<string name="screen_room_attachment_source_camera_video">"Gravar vídeo"</string>
|
||||
<string name="screen_room_attachment_source_files">"Anexo"</string>
|
||||
<string name="screen_room_attachment_source_gallery">"Biblioteca de fotos e vídeos"</string>
|
||||
<string name="screen_room_attachment_source_location">"Localização"</string>
|
||||
<string name="screen_room_attachment_source_location">"Compartilhar localização"</string>
|
||||
<string name="screen_room_attachment_source_poll">"Enquete"</string>
|
||||
<string name="screen_room_attachment_text_formatting">"Formatação de texto"</string>
|
||||
<string name="screen_room_encrypted_history_banner">"O histórico de mensagens não está disponível no momento."</string>
|
||||
|
||||
@@ -16,16 +16,30 @@
|
||||
<string name="emoji_picker_category_places">"Путешествия и места"</string>
|
||||
<string name="emoji_picker_category_recent">"Недавние эмодзи"</string>
|
||||
<string name="emoji_picker_category_symbols">"Символы"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"Перевернуть изображение по горизонтали"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Перевернуто по горизонтали"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Оригинал"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically">"Перевернуть изображение по вертикали"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Перевернуто по вертикали"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Оригинал"</string>
|
||||
<string name="screen_image_edition_a11y_rotate_to_the_left">"Повернуть изображение влево."</string>
|
||||
<plurals name="screen_image_edition_a11y_rotation_state">
|
||||
<item quantity="one">"%1$d градус"</item>
|
||||
<item quantity="few">"%1$d градуса"</item>
|
||||
<item quantity="many">"%1$d градусов"</item>
|
||||
</plurals>
|
||||
<string name="screen_image_edition_title">"Редактировать фото"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Подпись может быть не видна пользователям старых приложений."</string>
|
||||
<string name="screen_media_upload_preview_change_video_quality_prompt">"Нажмите, чтобы изменить качество загружаемого видео."</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Файл не может быть загружен."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Не удалось обработать медиафайл для загрузки, попробуйте еще раз."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Не удалось загрузить медиафайлы, попробуйте еще раз."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Не удалось обработать медиафайл для загрузки, попробуйте ещё раз."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Не удалось загрузить медиафайлы, попробуйте ещё раз."</string>
|
||||
<string name="screen_media_upload_preview_error_too_large_message">"Максимальный размер файла: %1$s."</string>
|
||||
<string name="screen_media_upload_preview_error_too_large_title">"Файл слишком большой для загрузки."</string>
|
||||
<string name="screen_media_upload_preview_item_count">"%1$d из %2$d"</string>
|
||||
<string name="screen_media_upload_preview_optimize_image_quality_title">"Оптимизировать качество изображения"</string>
|
||||
<string name="screen_media_upload_preview_processing">"Обработка…"</string>
|
||||
<string name="screen_media_upload_preview_title">"Добавить медиа"</string>
|
||||
<string name="screen_report_content_block_user">"Заблокировать пользователя"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Отметьте, хотите ли вы скрыть все текущие и будущие сообщения от этого пользователя"</string>
|
||||
<string name="screen_report_content_explanation">"Это сообщение будет передано администратору вашего домашнего сервера. Они не смогут прочитать зашифрованные сообщения."</string>
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="crypto_event_authenticity_mismatched_sender">"Відправник події не збігається з власником пристрою, який його надіслав."</string>
|
||||
<string name="crypto_event_authenticity_not_guaranteed">"Автентичність цього зашифрованого повідомлення не може бути гарантована на цьому пристрої."</string>
|
||||
<string name="crypto_event_authenticity_previously_verified">"Зашифровано попередньо перевіреним користувачем."</string>
|
||||
<string name="crypto_event_authenticity_previously_verified">"Зашифровано звіреним раніше користувачем."</string>
|
||||
<string name="crypto_event_authenticity_sent_in_clear">"Не зашифровано."</string>
|
||||
<string name="crypto_event_authenticity_unknown_device">"Зашифровано невідомим або видаленим пристроєм."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Зашифровано пристроєм, який не верифіковано його власником."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Зашифровано неверифікованим користувачем."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Зашифровано пристроєм, який не звірено його власником."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Зашифровано не звіреним користувачем."</string>
|
||||
<string name="emoji_picker_category_activity">"Дії"</string>
|
||||
<string name="emoji_picker_category_flags">"Прапори"</string>
|
||||
<string name="emoji_picker_category_foods">"Їжа та напої"</string>
|
||||
@@ -16,6 +16,19 @@
|
||||
<string name="emoji_picker_category_places">"Подорожі та місця"</string>
|
||||
<string name="emoji_picker_category_recent">"Нещодавні емодзі"</string>
|
||||
<string name="emoji_picker_category_symbols">"Символи"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"Повернути зображення по горизонталі"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Перевернуто по горизонталі"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Оригінал"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically">"Повернути зображення по вертикалі"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Перевернуто по вертикалі"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Оригінал"</string>
|
||||
<string name="screen_image_edition_a11y_rotate_to_the_left">"Повернути зображення вліво"</string>
|
||||
<plurals name="screen_image_edition_a11y_rotation_state">
|
||||
<item quantity="one">"%1$d градус"</item>
|
||||
<item quantity="few">"%1$d градуси"</item>
|
||||
<item quantity="many">"%1$d градусів"</item>
|
||||
</plurals>
|
||||
<string name="screen_image_edition_title">"Редагувати фото"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Користувачі старих застосунків можуть не бачити підписи."</string>
|
||||
<string name="screen_media_upload_preview_change_video_quality_prompt">"Натисніть, щоб змінити якість вивантажуваного відео"</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Файл не може бути вивантажено."</string>
|
||||
@@ -26,6 +39,7 @@
|
||||
<string name="screen_media_upload_preview_item_count">"Елемент %1$d з %2$d"</string>
|
||||
<string name="screen_media_upload_preview_optimize_image_quality_title">"Оптимізувати якість зображення"</string>
|
||||
<string name="screen_media_upload_preview_processing">"Обробка…"</string>
|
||||
<string name="screen_media_upload_preview_title">"Додати медіа"</string>
|
||||
<string name="screen_report_content_block_user">"Заблокувати користувача"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Перевірте, чи хочете ви приховати всі поточні та майбутні повідомлення від цього користувача"</string>
|
||||
<string name="screen_report_content_explanation">"Це повідомлення буде надіслано адміністраторам вашого домашнього сервера. Вони не зможуть прочитати зашифровані повідомлення."</string>
|
||||
@@ -39,7 +53,7 @@
|
||||
<string name="screen_room_attachment_source_poll">"Опитування"</string>
|
||||
<string name="screen_room_attachment_text_formatting">"Форматування тексту"</string>
|
||||
<string name="screen_room_encrypted_history_banner">"Історія повідомлень наразі недоступна."</string>
|
||||
<string name="screen_room_encrypted_history_banner_unverified">"Історія повідомлень недоступна в цій кімнаті. Перевірте цей пристрій, щоб побачити історію повідомлень."</string>
|
||||
<string name="screen_room_encrypted_history_banner_unverified">"Історія повідомлень недоступна в цій кімнаті. Звірте цей пристрій, щоб побачити історію повідомлень."</string>
|
||||
<string name="screen_room_invite_again_alert_message">"Чи хотіли б ви запросити їх знову?"</string>
|
||||
<string name="screen_room_invite_again_alert_title">"Ви одні в цій бесіді"</string>
|
||||
<string name="screen_room_mentions_at_room_subtitle">"Сповістити всю кімнату"</string>
|
||||
|
||||
@@ -16,6 +16,17 @@
|
||||
<string name="emoji_picker_category_places">"文旅景点"</string>
|
||||
<string name="emoji_picker_category_recent">"最近的 Emoji"</string>
|
||||
<string name="emoji_picker_category_symbols">"符号"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"水平翻转图像"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"水平翻转"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"原始"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically">"垂直翻转图像"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"垂直翻转"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"原始"</string>
|
||||
<string name="screen_image_edition_a11y_rotate_to_the_left">"向左旋转图像"</string>
|
||||
<plurals name="screen_image_edition_a11y_rotation_state">
|
||||
<item quantity="other">"%1$d 度"</item>
|
||||
</plurals>
|
||||
<string name="screen_image_edition_title">"编辑照片"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"使用旧版应用程序的用户可能无法看到字幕。"</string>
|
||||
<string name="screen_media_upload_preview_change_video_quality_prompt">"点按以更改视频上传质量"</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"无法上传该文件。"</string>
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
<string name="emoji_picker_category_places">"Travel & Places"</string>
|
||||
<string name="emoji_picker_category_recent">"Recent emojis"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symbols"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"Flip image horizontally"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Flipped horizontally"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Original"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically">"Flip image vertically"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Flipped vertically"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Original"</string>
|
||||
<string name="screen_image_edition_a11y_rotate_to_the_left">"Rotate the image to the left"</string>
|
||||
<plurals name="screen_image_edition_a11y_rotation_state">
|
||||
<item quantity="one">"%1$d degree"</item>
|
||||
|
||||
+3
@@ -23,6 +23,7 @@ import io.element.android.features.messages.api.MessagesEntryPoint
|
||||
import io.element.android.features.messages.impl.pinned.banner.createPinnedEventsTimelineProvider
|
||||
import io.element.android.features.messages.impl.timeline.createTimelineController
|
||||
import io.element.android.features.poll.test.create.FakeCreatePollEntryPoint
|
||||
import io.element.android.libraries.androidutils.system.DeviceHasVulkanSupport
|
||||
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
@@ -42,6 +43,7 @@ import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.node.TestParentNode
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -85,6 +87,7 @@ class DefaultMessagesEntryPointTest {
|
||||
knockRequestsListEntryPoint = FakeKnockRequestsListEntryPoint(),
|
||||
dateFormatter = FakeDateFormatter(),
|
||||
coroutineDispatchers = testCoroutineDispatchers(),
|
||||
hasVulkanSupport = DeviceHasVulkanSupport(mockk(relaxed = true))
|
||||
)
|
||||
}
|
||||
val callback = object : MessagesEntryPoint.Callback {
|
||||
|
||||
+5
-5
@@ -29,7 +29,6 @@ import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.test.swipeRight
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.emojibasebindings.Emoji
|
||||
import io.element.android.emojibasebindings.EmojibaseCategory
|
||||
import io.element.android.emojibasebindings.EmojibaseStore
|
||||
@@ -69,6 +68,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureCalledOnceWithTwoParamsAndResult
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithThreeParamsAndResult
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParamsAndResult
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
@@ -78,16 +78,15 @@ import io.element.android.tests.testutils.ensureCalledOnce
|
||||
import io.element.android.tests.testutils.ensureCalledOnceWithParam
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.pressBack
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import io.element.android.tests.testutils.setSafeContent
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MessagesViewTest {
|
||||
class MessagesViewTest : RobolectricTest() {
|
||||
@Test
|
||||
fun `clicking on back invoke expected callback`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false)
|
||||
@@ -238,7 +237,6 @@ class MessagesViewTest {
|
||||
val eventsRecorder = EventsRecorder<ReadReceiptBottomSheetEvent>()
|
||||
val state = aMessagesState(
|
||||
timelineState = aTimelineState(
|
||||
renderReadReceipts = true,
|
||||
timelineItems = persistentListOf(
|
||||
aTimelineItemEvent(
|
||||
readReceiptState = aTimelineItemReadReceipts(
|
||||
@@ -688,6 +686,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setMessagesView(
|
||||
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||
onRoomDetailsClick: () -> Unit = EnsureNeverCalled(),
|
||||
onEventClick: (isLive: Boolean, event: TimelineItem.Event) -> Boolean = EnsureNeverCalledWithTwoParamsAndResult(),
|
||||
onGalleryEventItemClick: (isLive: Boolean, event: TimelineItem.Event, index: Int) -> Boolean = EnsureNeverCalledWithThreeParamsAndResult(),
|
||||
onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onLinkClick: (String, Boolean) -> Unit = EnsureNeverCalledWithTwoParams(),
|
||||
onSendLocationClick: () -> Unit = EnsureNeverCalled(),
|
||||
@@ -704,6 +703,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setMessagesView(
|
||||
onBackClick = onBackClick,
|
||||
onRoomDetailsClick = onRoomDetailsClick,
|
||||
onEventContentClick = onEventClick,
|
||||
onGalleryEventItemClick = onGalleryEventItemClick,
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClick = onLinkClick,
|
||||
onSendLocationClick = onSendLocationClick,
|
||||
|
||||
+157
-49
@@ -33,6 +33,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||
import io.element.android.libraries.matrix.api.media.GalleryItemInfo
|
||||
import io.element.android.libraries.matrix.api.media.ImageInfo
|
||||
import io.element.android.libraries.matrix.api.media.VideoInfo
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
|
||||
@@ -64,11 +65,13 @@ import io.element.android.tests.testutils.lambda.any
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import io.element.android.tests.testutils.test
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
@@ -76,14 +79,11 @@ import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.io.File
|
||||
import kotlin.io.path.createTempFile
|
||||
|
||||
@Suppress("LargeClass")
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class AttachmentsPreviewPresenterTest {
|
||||
class AttachmentsPreviewPresenterTest : RobolectricTest() {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
@@ -94,6 +94,7 @@ class AttachmentsPreviewPresenterTest {
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
createAttachmentsPreviewPresenter().test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
|
||||
}
|
||||
@@ -118,13 +119,14 @@ class AttachmentsPreviewPresenterTest {
|
||||
onDoneListener = { onDoneListener() },
|
||||
)
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false))
|
||||
initialState.eventSink(AttachmentsPreviewEvent.SendAttachment)
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = true))
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo))
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo))
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo)))
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(listOf(mediaUploadInfo)))
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
|
||||
sendFileResult.assertions().isCalledOnce()
|
||||
onDoneListener.assertions().isCalledOnce()
|
||||
@@ -152,6 +154,7 @@ class AttachmentsPreviewPresenterTest {
|
||||
onDoneListener = { onDoneListener() },
|
||||
)
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
|
||||
// Pre-processing finishes
|
||||
@@ -159,8 +162,8 @@ class AttachmentsPreviewPresenterTest {
|
||||
advanceUntilIdle()
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false))
|
||||
initialState.eventSink(AttachmentsPreviewEvent.SendAttachment)
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo))
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo))
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo)))
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(listOf(mediaUploadInfo)))
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
|
||||
sendFileResult.assertions().isCalledOnce()
|
||||
onDoneListener.assertions().isCalledOnce()
|
||||
@@ -188,6 +191,7 @@ class AttachmentsPreviewPresenterTest {
|
||||
onDoneListener = { onDoneListener() },
|
||||
)
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false))
|
||||
@@ -195,8 +199,8 @@ class AttachmentsPreviewPresenterTest {
|
||||
// Pre-processing finishes
|
||||
processLatch.complete(Unit)
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = true))
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo))
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo))
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo)))
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(listOf(mediaUploadInfo)))
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
|
||||
sendFileResult.assertions().isCalledOnce()
|
||||
onDoneListener.assertions().isCalledOnce()
|
||||
@@ -217,6 +221,7 @@ class AttachmentsPreviewPresenterTest {
|
||||
onDoneListener = { onDoneListener() },
|
||||
)
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
|
||||
initialState.eventSink(AttachmentsPreviewEvent.SendAttachment)
|
||||
@@ -240,6 +245,7 @@ class AttachmentsPreviewPresenterTest {
|
||||
onDoneListener = { onDoneListener() },
|
||||
)
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
|
||||
// Pre-processing finishes
|
||||
@@ -262,6 +268,7 @@ class AttachmentsPreviewPresenterTest {
|
||||
onDoneListener = { onDoneListener() },
|
||||
)
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
|
||||
initialState.eventSink(AttachmentsPreviewEvent.CancelAndDismiss)
|
||||
@@ -293,6 +300,7 @@ class AttachmentsPreviewPresenterTest {
|
||||
onDoneListener = { onDoneListener() },
|
||||
)
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
|
||||
initialState.textEditorState.setMarkdown(A_CAPTION)
|
||||
@@ -334,6 +342,7 @@ class AttachmentsPreviewPresenterTest {
|
||||
onDoneListener = { onDoneListener() },
|
||||
)
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
|
||||
initialState.textEditorState.setMarkdown(A_CAPTION)
|
||||
@@ -375,6 +384,7 @@ class AttachmentsPreviewPresenterTest {
|
||||
onDoneListener = { onDoneListener() },
|
||||
)
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
|
||||
initialState.textEditorState.setMarkdown(A_CAPTION)
|
||||
@@ -409,22 +419,19 @@ class AttachmentsPreviewPresenterTest {
|
||||
)
|
||||
val presenter = createAttachmentsPreviewPresenter(room = room, onDoneListener = onDoneListenerResult)
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
|
||||
initialState.eventSink(AttachmentsPreviewEvent.SendAttachment)
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false))
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo))
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo))
|
||||
|
||||
// Check that the onDoneListener is called so the screen would be dismissed
|
||||
onDoneListenerResult.assertions().isCalledOnce()
|
||||
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo)))
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(listOf(mediaUploadInfo)))
|
||||
val failureState = awaitItem()
|
||||
assertThat(failureState.sendActionState).isEqualTo(SendActionState.Failure(failure, mediaUploadInfo))
|
||||
assertThat(failureState.sendActionState).isEqualTo(SendActionState.Failure(failure, listOf(mediaUploadInfo)))
|
||||
sendFileResult.assertions().isCalledOnce()
|
||||
failureState.eventSink(AttachmentsPreviewEvent.CancelAndClearSendState)
|
||||
val clearedState = awaitLastSequentialItem()
|
||||
assertThat(clearedState.sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo))
|
||||
assertThat(clearedState.sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,19 +449,17 @@ class AttachmentsPreviewPresenterTest {
|
||||
onDoneListener = onDoneListenerResult,
|
||||
)
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
|
||||
initialState.eventSink(AttachmentsPreviewEvent.SendAttachment)
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false))
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo))
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo))
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo)))
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(listOf(mediaUploadInfo)))
|
||||
initialState.eventSink(AttachmentsPreviewEvent.CancelAndClearSendState)
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo))
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(listOf(mediaUploadInfo)))
|
||||
// The sending is cancelled and the state is kept at ReadyToUpload
|
||||
ensureAllEventsConsumed()
|
||||
|
||||
// Check that the onDoneListener is called so the screen would be dismissed
|
||||
onDoneListenerResult.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,7 +471,7 @@ class AttachmentsPreviewPresenterTest {
|
||||
val maxUploadSize = 999L // Set a max upload size smaller than the file size
|
||||
|
||||
val presenter = createAttachmentsPreviewPresenter(
|
||||
localMedia = localMedia,
|
||||
attachments = listOf(Attachment.Media(localMedia)),
|
||||
room = FakeJoinedRoom(
|
||||
liveTimeline = FakeTimeline().apply {
|
||||
sendFileLambda = { _, _, _, _, _ ->
|
||||
@@ -477,6 +482,7 @@ class AttachmentsPreviewPresenterTest {
|
||||
onDoneListener = onDoneListenerResult,
|
||||
mediaOptimizationSelectorPresenterFactory = FakeMediaOptimizationSelectorPresenterFactory {
|
||||
MediaOptimizationSelectorState(
|
||||
index = 0,
|
||||
// Set a max upload size smaller than the file size
|
||||
maxUploadSize = AsyncData.Success(maxUploadSize),
|
||||
videoSizeEstimations = AsyncData.Uninitialized,
|
||||
@@ -505,7 +511,7 @@ class AttachmentsPreviewPresenterTest {
|
||||
val localMedia = aLocalMedia(uri = Uri.EMPTY, mediaInfo = aVideoMediaInfo())
|
||||
|
||||
val presenter = createAttachmentsPreviewPresenter(
|
||||
localMedia = localMedia,
|
||||
attachments = listOf(Attachment.Media(localMedia)),
|
||||
room = FakeJoinedRoom(
|
||||
liveTimeline = FakeTimeline().apply {
|
||||
sendFileLambda = { _, _, _, _, _ ->
|
||||
@@ -516,6 +522,7 @@ class AttachmentsPreviewPresenterTest {
|
||||
onDoneListener = onDoneListenerResult,
|
||||
mediaOptimizationSelectorPresenterFactory = FakeMediaOptimizationSelectorPresenterFactory {
|
||||
MediaOptimizationSelectorState(
|
||||
index = 0,
|
||||
// Set a max upload size smaller than the file size
|
||||
maxUploadSize = AsyncData.Success(Long.MAX_VALUE),
|
||||
videoSizeEstimations = AsyncData.Success(
|
||||
@@ -573,6 +580,7 @@ class AttachmentsPreviewPresenterTest {
|
||||
)
|
||||
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
|
||||
val editorState = awaitItem()
|
||||
@@ -586,7 +594,7 @@ class AttachmentsPreviewPresenterTest {
|
||||
assertThat(awaitItem().isApplyingImageEdits).isTrue()
|
||||
|
||||
val appliedState = awaitItem()
|
||||
assertThat((appliedState.attachment as Attachment.Media).localMedia.uri).isEqualTo(editedUri)
|
||||
assertThat((appliedState.attachments.first() as Attachment.Media).localMedia.uri).isEqualTo(editedUri)
|
||||
assertThat(appliedState.imageEditorState).isNull()
|
||||
assertThat(appliedState.isApplyingImageEdits).isFalse()
|
||||
}
|
||||
@@ -603,7 +611,7 @@ class AttachmentsPreviewPresenterTest {
|
||||
bottom = 0.9f,
|
||||
)
|
||||
val presenter = createAttachmentsPreviewPresenter(
|
||||
localMedia = originalLocalMedia,
|
||||
attachments = listOf(Attachment.Media(originalLocalMedia)),
|
||||
displayMediaQualitySelectorViews = true,
|
||||
attachmentImageEditor = FakeAttachmentImageEditor {
|
||||
Result.success(
|
||||
@@ -624,10 +632,14 @@ class AttachmentsPreviewPresenterTest {
|
||||
val croppedState = awaitItem()
|
||||
croppedState.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft)
|
||||
val rotatedState = awaitItem()
|
||||
rotatedState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits)
|
||||
rotatedState.eventSink(AttachmentsPreviewEvent.FlipImageHorizontally)
|
||||
val flippedHorizontallyState = awaitItem()
|
||||
flippedHorizontallyState.eventSink(AttachmentsPreviewEvent.FlipImageVertically)
|
||||
val flippedState = awaitItem()
|
||||
flippedState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits)
|
||||
|
||||
val appliedState = consumeItemsUntilPredicate { !it.isApplyingImageEdits && it.imageEditorState == null }.last()
|
||||
assertThat((appliedState.attachment as Attachment.Media).localMedia.uri).isEqualTo(editedUri)
|
||||
assertThat((appliedState.attachments.first() as Attachment.Media).localMedia.uri).isEqualTo(editedUri)
|
||||
|
||||
appliedState.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
|
||||
val reopenedState = consumeItemsUntilPredicate { it.imageEditorState != null }.last()
|
||||
@@ -638,27 +650,59 @@ class AttachmentsPreviewPresenterTest {
|
||||
right = cropRect.bottom,
|
||||
bottom = 1f - cropRect.left,
|
||||
)
|
||||
reopenedState.imageEditorState.edits.cropRect.assertIsSimilarTo(rotatedCropRect)
|
||||
val flippedCropRect = NormalizedCropRect(
|
||||
left = 1f - rotatedCropRect.right,
|
||||
top = 1f - rotatedCropRect.bottom,
|
||||
right = 1f - rotatedCropRect.left,
|
||||
bottom = 1f - rotatedCropRect.top,
|
||||
)
|
||||
reopenedState.imageEditorState.edits.cropRect.assertIsSimilarTo(flippedCropRect)
|
||||
assertThat(reopenedState.imageEditorState.edits.rotationQuarterTurns).isEqualTo(3)
|
||||
assertThat(reopenedState.imageEditorState.edits.rotationDegrees).isEqualTo(270)
|
||||
assertThat(reopenedState.imageEditorState.edits.isFlippedHorizontally).isTrue()
|
||||
assertThat(reopenedState.imageEditorState.edits.isFlippedVertically).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - image editor flip events update edits`() = runTest {
|
||||
val presenter = createAttachmentsPreviewPresenter(displayMediaQualitySelectorViews = true)
|
||||
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
|
||||
val editorState = consumeItemsUntilPredicate { it.imageEditorState != null }.last()
|
||||
|
||||
editorState.eventSink(AttachmentsPreviewEvent.FlipImageHorizontally)
|
||||
val flippedHorizontallyState = awaitItem()
|
||||
assertThat(flippedHorizontallyState.imageEditorState?.edits?.isFlippedHorizontally).isTrue()
|
||||
|
||||
flippedHorizontallyState.eventSink(AttachmentsPreviewEvent.FlipImageVertically)
|
||||
val flippedState = awaitItem()
|
||||
assertThat(flippedState.imageEditorState?.edits?.isFlippedVertically).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - sendAsFile attachment is pre-processed without image compression`() = runTest {
|
||||
// Even though the user has enabled "Optimize media quality" globally, picking the file
|
||||
// through the Files picker (sendAsFile = true) must skip compression. Regression test
|
||||
// for https://github.com/element-hq/element-x-android/issues/6365
|
||||
val mediaPreProcessor = FakeMediaPreProcessor()
|
||||
val presenter = createAttachmentsPreviewPresenter(
|
||||
localMedia = aLocalMedia(mockMediaUrl, anImageMediaInfo()),
|
||||
sendAsFile = true,
|
||||
attachments = listOf(
|
||||
Attachment.Media(
|
||||
localMedia = aLocalMedia(mockMediaUrl, anImageMediaInfo()),
|
||||
sendAsFile = true,
|
||||
)
|
||||
),
|
||||
mediaPreProcessor = mediaPreProcessor,
|
||||
// Selector views are hidden in the sendAsFile flow, which triggers the auto pre-process path.
|
||||
displayMediaQualitySelectorViews = false,
|
||||
mediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(
|
||||
config = MediaOptimizationConfig(
|
||||
compressImages = true,
|
||||
videoCompressionPreset = VideoCompressionPreset.STANDARD,
|
||||
compressImages = false,
|
||||
videoCompressionPreset = VideoCompressionPreset.HIGH,
|
||||
)
|
||||
),
|
||||
)
|
||||
@@ -743,9 +787,11 @@ class AttachmentsPreviewPresenterTest {
|
||||
fileExtension = "png",
|
||||
),
|
||||
)
|
||||
val presenter = createAttachmentsPreviewPresenter(localMedia = localMedia)
|
||||
|
||||
val presenter = createAttachmentsPreviewPresenter(
|
||||
attachments = listOf(Attachment.Media(localMedia)),
|
||||
)
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.canEditImage).isTrue()
|
||||
|
||||
@@ -766,7 +812,7 @@ class AttachmentsPreviewPresenterTest {
|
||||
),
|
||||
)
|
||||
val presenter = createAttachmentsPreviewPresenter(
|
||||
localMedia = localMedia,
|
||||
attachments = listOf(Attachment.Media(localMedia)),
|
||||
attachmentImageEditor = FakeAttachmentImageEditor(
|
||||
canEditResult = true,
|
||||
) {
|
||||
@@ -793,13 +839,18 @@ class AttachmentsPreviewPresenterTest {
|
||||
fun `present - sendAsFile video is pre-processed with best fitting preset`() = runTest {
|
||||
val mediaPreProcessor = FakeMediaPreProcessor()
|
||||
val presenter = createAttachmentsPreviewPresenter(
|
||||
localMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()),
|
||||
sendAsFile = true,
|
||||
attachments = listOf(
|
||||
Attachment.Media(
|
||||
localMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()),
|
||||
sendAsFile = true,
|
||||
)
|
||||
),
|
||||
mediaPreProcessor = mediaPreProcessor,
|
||||
// Selector views are hidden in the sendAsFile flow, which triggers the auto pre-process path.
|
||||
displayMediaQualitySelectorViews = false,
|
||||
mediaOptimizationSelectorPresenterFactory = FakeMediaOptimizationSelectorPresenterFactory {
|
||||
MediaOptimizationSelectorState(
|
||||
index = 0,
|
||||
maxUploadSize = AsyncData.Success(250_000_000L),
|
||||
videoSizeEstimations = AsyncData.Success(
|
||||
persistentListOf(
|
||||
@@ -834,11 +885,62 @@ class AttachmentsPreviewPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - sending gallery after image edits restarts preprocessing`() = runTest {
|
||||
val sendGalleryResult =
|
||||
lambdaRecorder<List<GalleryItemInfo>, String?, String?, EventId?, Result<FakeMediaUploadHandler>> { _, _, _, _ ->
|
||||
Result.success(FakeMediaUploadHandler())
|
||||
}
|
||||
val firstLocalMedia = aLocalMedia(uri = Uri.parse("file:///tmp/original-1.jpeg"))
|
||||
val secondLocalMedia = aLocalMedia(uri = Uri.parse("file:///tmp/original-2.jpeg"))
|
||||
val editedUri = Uri.parse("file:///tmp/edited-1.jpeg")
|
||||
val onDoneListener = lambdaRecorder<Unit> { }
|
||||
val presenter = createAttachmentsPreviewPresenter(
|
||||
room = FakeJoinedRoom(
|
||||
liveTimeline = FakeTimeline().apply {
|
||||
sendGalleryLambda = sendGalleryResult
|
||||
},
|
||||
),
|
||||
attachments = persistentListOf(
|
||||
aMediaAttachment(firstLocalMedia),
|
||||
aMediaAttachment(secondLocalMedia),
|
||||
),
|
||||
displayMediaQualitySelectorViews = false,
|
||||
attachmentImageEditor = FakeAttachmentImageEditor {
|
||||
Result.success(
|
||||
EditedLocalMedia(
|
||||
localMedia = firstLocalMedia.copy(uri = editedUri),
|
||||
file = File("/tmp/edited-1.jpeg"),
|
||||
)
|
||||
)
|
||||
},
|
||||
onDoneListener = OnDoneListener { onDoneListener() },
|
||||
)
|
||||
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
|
||||
val editorState = consumeItemsUntilPredicate { it.imageEditorState != null }.last()
|
||||
|
||||
editorState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits)
|
||||
val appliedState = consumeItemsUntilPredicate { !it.isApplyingImageEdits && it.imageEditorState == null }.last()
|
||||
|
||||
appliedState.eventSink(AttachmentsPreviewEvent.SendAttachment)
|
||||
consumeItemsUntilPredicate { it.sendActionState == SendActionState.Done }
|
||||
|
||||
sendGalleryResult.assertions().isCalledOnce()
|
||||
onDoneListener.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestScope.createAttachmentsPreviewPresenter(
|
||||
localMedia: LocalMedia = aLocalMedia(
|
||||
uri = mockMediaUrl,
|
||||
attachments: List<Attachment> = listOf(
|
||||
aMediaAttachment(
|
||||
aLocalMedia(
|
||||
uri = mockMediaUrl,
|
||||
)
|
||||
),
|
||||
),
|
||||
sendAsFile: Boolean = false,
|
||||
room: JoinedRoom = FakeJoinedRoom(),
|
||||
timelineMode: Timeline.Mode = Timeline.Mode.Live,
|
||||
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
|
||||
@@ -849,6 +951,7 @@ class AttachmentsPreviewPresenterTest {
|
||||
mediaOptimizationSelectorPresenterFactory: FakeMediaOptimizationSelectorPresenterFactory = FakeMediaOptimizationSelectorPresenterFactory(
|
||||
fakePresenter = {
|
||||
MediaOptimizationSelectorState(
|
||||
index = 0,
|
||||
maxUploadSize = AsyncData.Uninitialized,
|
||||
videoSizeEstimations = AsyncData.Uninitialized,
|
||||
isImageOptimizationEnabled = null,
|
||||
@@ -861,17 +964,22 @@ class AttachmentsPreviewPresenterTest {
|
||||
),
|
||||
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
|
||||
attachmentImageEditor: AttachmentImageEditor = FakeAttachmentImageEditor {
|
||||
Result.success(
|
||||
EditedLocalMedia(
|
||||
localMedia = localMedia.copy(uri = Uri.parse("file:///tmp/default-edited.jpeg")),
|
||||
file = File("/tmp/default-edited.jpeg"),
|
||||
val localMediaResult = (attachments.first() as? Attachment.Media)?.localMedia?.copy(uri = Uri.parse("file:///tmp/default-edited.jpeg"))
|
||||
if (localMediaResult != null) {
|
||||
Result.success(
|
||||
EditedLocalMedia(
|
||||
localMedia = localMediaResult,
|
||||
file = File("/tmp/default-edited.jpeg"),
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Result.failure(IllegalStateException("Check test values"))
|
||||
}
|
||||
},
|
||||
videoCompressionPresetSelector: VideoCompressionPresetSelector = VideoCompressionPresetSelector(),
|
||||
): AttachmentsPreviewPresenter {
|
||||
return AttachmentsPreviewPresenter(
|
||||
attachment = aMediaAttachment(localMedia, sendAsFile = sendAsFile),
|
||||
attachments = attachments.toImmutableList(),
|
||||
onDoneListener = onDoneListener,
|
||||
mediaSenderFactory = MediaSenderFactory { timelineMode ->
|
||||
DefaultMediaSender(
|
||||
|
||||
+12
-13
@@ -11,28 +11,27 @@ package io.element.android.features.messages.impl.attachments
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.messages.impl.attachments.preview.SendActionState
|
||||
import io.element.android.features.messages.impl.attachments.preview.aMediaUploadInfo
|
||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
||||
import org.junit.Test
|
||||
|
||||
class SendActionStateTest {
|
||||
@Test
|
||||
fun `mediaUploadInfo() should return the value from Uploading class`() {
|
||||
val mediaUploadInfo: MediaUploadInfo = aMediaUploadInfo()
|
||||
val state: SendActionState = SendActionState.Sending.Uploading(mediaUploadInfo = aMediaUploadInfo())
|
||||
assertThat(state.mediaUploadInfo()).isEqualTo(mediaUploadInfo)
|
||||
fun `mediaUploadInfoList() should return the value from Uploading class`() {
|
||||
val data = listOf(aMediaUploadInfo())
|
||||
val state: SendActionState = SendActionState.Sending.Uploading(mediaInfos = data)
|
||||
assertThat(state.mediaUploadInfoList()).isEqualTo(data)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mediaUploadInfo() should return the value from ReadyToUpload class`() {
|
||||
val mediaUploadInfo: MediaUploadInfo = aMediaUploadInfo()
|
||||
val state: SendActionState = SendActionState.Sending.ReadyToUpload(mediaInfo = aMediaUploadInfo())
|
||||
assertThat(state.mediaUploadInfo()).isEqualTo(mediaUploadInfo)
|
||||
fun `mediaUploadInfoList() should return the value from ReadyToUpload class`() {
|
||||
val data = listOf(aMediaUploadInfo())
|
||||
val state: SendActionState = SendActionState.Sending.ReadyToUpload(mediaInfos = data)
|
||||
assertThat(state.mediaUploadInfoList()).isEqualTo(data)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mediaUploadInfo() should return the value from Failure class`() {
|
||||
val mediaUploadInfo: MediaUploadInfo = aMediaUploadInfo()
|
||||
val state: SendActionState = SendActionState.Failure(error = IllegalStateException("An error"), mediaUploadInfo = aMediaUploadInfo())
|
||||
assertThat(state.mediaUploadInfo()).isEqualTo(mediaUploadInfo)
|
||||
fun `mediaUploadInfoList() should return the value from Failure class`() {
|
||||
val data = listOf(aMediaUploadInfo())
|
||||
val state: SendActionState = SendActionState.Failure(error = IllegalStateException("An error"), mediaInfos = data)
|
||||
assertThat(state.mediaUploadInfoList()).isEqualTo(data)
|
||||
}
|
||||
}
|
||||
|
||||
+33
@@ -42,4 +42,37 @@ class AttachmentImageEditsTest {
|
||||
assertThat(result.cropRect.bottom).isWithin(0.0001f).of(0.8f)
|
||||
assertThat(result.hasChanges).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flip horizontally updates crop and change tracking`() {
|
||||
val sut = AttachmentImageEdits(
|
||||
cropRect = NormalizedCropRect(
|
||||
left = 0.1f,
|
||||
top = 0.3f,
|
||||
right = 0.6f,
|
||||
bottom = 0.9f,
|
||||
)
|
||||
)
|
||||
val result = sut.flipHorizontally()
|
||||
assertThat(result.isFlippedHorizontally).isTrue()
|
||||
assertThat(result.cropRect.left).isWithin(0.0001f).of(0.4f)
|
||||
assertThat(result.cropRect.right).isWithin(0.0001f).of(0.9f)
|
||||
assertThat(result.cropRect.top).isWithin(0.0001f).of(0.3f)
|
||||
assertThat(result.cropRect.bottom).isWithin(0.0001f).of(0.9f)
|
||||
assertThat(result.hasChanges).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flip vertical twice resets to default state`() {
|
||||
val edits = AttachmentImageEdits().flipVertically().flipVertically()
|
||||
assertThat(edits.isFlippedVertically).isFalse()
|
||||
assertThat(edits.hasChanges).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flip horizontally twice resets to default state`() {
|
||||
val edits = AttachmentImageEdits().flipHorizontally().flipHorizontally()
|
||||
assertThat(edits.isFlippedVertically).isFalse()
|
||||
assertThat(edits.hasChanges).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -10,7 +10,6 @@ package io.element.android.features.messages.impl.attachments.video
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Size
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.messages.test.attachments.video.FakeVideoMetadataExtractor
|
||||
import io.element.android.features.messages.test.attachments.video.FakeVideoMetadataExtractorFactory
|
||||
@@ -26,16 +25,15 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
|
||||
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import io.element.android.tests.testutils.test
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DefaultMediaOptimizationSelectorPresenterTest {
|
||||
class DefaultMediaOptimizationSelectorPresenterTest : RobolectricTest() {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
@@ -276,6 +274,7 @@ class DefaultMediaOptimizationSelectorPresenterTest {
|
||||
}
|
||||
|
||||
private fun createDefaultMediaOptimizationSelectorPresenter(
|
||||
index: Int = 0,
|
||||
localMedia: LocalMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()),
|
||||
maxUploadSizeProvider: MaxUploadSizeProvider = MaxUploadSizeProvider { Result.success(1_000L) },
|
||||
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SelectableMediaQuality.key to true)),
|
||||
@@ -285,6 +284,7 @@ class DefaultMediaOptimizationSelectorPresenterTest {
|
||||
sendAsFile: Boolean = false,
|
||||
): DefaultMediaOptimizationSelectorPresenter {
|
||||
return DefaultMediaOptimizationSelectorPresenter(
|
||||
index = index,
|
||||
localMedia = localMedia,
|
||||
sendAsFile = sendAsFile,
|
||||
maxUploadSizeProvider = maxUploadSizeProvider,
|
||||
|
||||
+2
-4
@@ -15,7 +15,6 @@ import androidx.compose.ui.test.AndroidComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
@@ -24,11 +23,10 @@ import io.element.android.libraries.matrix.ui.room.RoomMemberIdentityStateChange
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class IdentityChangeStateViewTest {
|
||||
class IdentityChangeStateViewTest : RobolectricTest() {
|
||||
@Test
|
||||
fun `show and resolve pin violation`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<IdentityChangeEvent>()
|
||||
|
||||
+2
-4
@@ -14,16 +14,14 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import io.element.android.tests.testutils.setSafeContent
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ResolveVerifiedUserSendFailureViewTest {
|
||||
class ResolveVerifiedUserSendFailureViewTest : RobolectricTest() {
|
||||
@Test
|
||||
fun `clicking on resolve and resend emit the expected event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<ResolveVerifiedUserSendFailureEvent>()
|
||||
|
||||
+2
-4
@@ -14,19 +14,17 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.ensureCalledOnceWithParam
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class LinkViewTest {
|
||||
class LinkViewTest : RobolectricTest() {
|
||||
@Test
|
||||
fun `clicking on cancel emits the expected event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<LinkEvent>()
|
||||
|
||||
+6
-2
@@ -24,6 +24,8 @@ import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter
|
||||
import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper
|
||||
import io.element.android.features.messages.impl.utils.TextPillificationHelper
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
@@ -271,12 +273,13 @@ class MessageComposerPresenterSlashCommandTest {
|
||||
isRichTextEditorEnabled: Boolean = true,
|
||||
draftService: ComposerDraftService = FakeComposerDraftService(),
|
||||
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
|
||||
isInThread: Boolean = false,
|
||||
threadRoot: ThreadId? = null,
|
||||
slashCommandService: SlashCommandService = FakeSlashCommandService(),
|
||||
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
|
||||
) = MessageComposerPresenter(
|
||||
navigator = navigator,
|
||||
sessionCoroutineScope = this,
|
||||
isInThread = isInThread,
|
||||
threadRoot = threadRoot,
|
||||
room = room,
|
||||
mediaPickerProvider = pickerProvider,
|
||||
sessionPreferencesStore = sessionPreferencesStore,
|
||||
@@ -311,6 +314,7 @@ class MessageComposerPresenterSlashCommandTest {
|
||||
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
|
||||
notificationConversationService = notificationConversationService,
|
||||
slashCommandService = slashCommandService,
|
||||
featureFlagService = featureFlagService,
|
||||
).apply {
|
||||
isTesting = true
|
||||
showTextFormatting = isRichTextEditorEnabled
|
||||
|
||||
+7
-6
@@ -34,6 +34,7 @@ import io.element.android.features.messages.impl.utils.TextPillificationHelper
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
@@ -107,6 +108,7 @@ import io.element.android.tests.testutils.lambda.any
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import io.element.android.tests.testutils.test
|
||||
import io.element.android.tests.testutils.waitForPredicate
|
||||
import io.mockk.mockk
|
||||
@@ -118,14 +120,11 @@ import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import uniffi.wysiwyg_composer.MentionsState
|
||||
import java.io.File
|
||||
|
||||
@Suppress("LargeClass")
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class MessageComposerPresenterTest {
|
||||
class MessageComposerPresenterTest : RobolectricTest() {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
@@ -1534,12 +1533,13 @@ class MessageComposerPresenterTest {
|
||||
isRichTextEditorEnabled: Boolean = true,
|
||||
draftService: ComposerDraftService = FakeComposerDraftService(),
|
||||
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
|
||||
isInThread: Boolean = false,
|
||||
threadRoot: ThreadId? = null,
|
||||
slashCommandService: SlashCommandService = FakeSlashCommandService(),
|
||||
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
|
||||
) = MessageComposerPresenter(
|
||||
navigator = navigator,
|
||||
sessionCoroutineScope = this,
|
||||
isInThread = isInThread,
|
||||
threadRoot = threadRoot,
|
||||
room = room,
|
||||
mediaPickerProvider = pickerProvider,
|
||||
sessionPreferencesStore = sessionPreferencesStore,
|
||||
@@ -1574,6 +1574,7 @@ class MessageComposerPresenterTest {
|
||||
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
|
||||
notificationConversationService = notificationConversationService,
|
||||
slashCommandService = slashCommandService,
|
||||
featureFlagService = featureFlagService,
|
||||
).apply {
|
||||
isTesting = true
|
||||
showTextFormatting = isRichTextEditorEnabled
|
||||
|
||||
+2
-4
@@ -16,7 +16,6 @@ import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
@@ -25,11 +24,10 @@ import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.ensureCalledOnce
|
||||
import io.element.android.tests.testutils.ensureCalledOnceWithParam
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PinnedMessagesBannerViewTest {
|
||||
class PinnedMessagesBannerViewTest : RobolectricTest() {
|
||||
@Test
|
||||
fun `clicking on the banner invoke expected callback`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<PinnedMessagesBannerEvent>()
|
||||
|
||||
+5
-4
@@ -19,7 +19,6 @@ import androidx.compose.ui.test.onFirst
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListEvent
|
||||
import io.element.android.features.messages.impl.actionlist.anActionListState
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemList
|
||||
@@ -28,17 +27,17 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.ensureCalledOnce
|
||||
import io.element.android.tests.testutils.ensureCalledOnceWithParam
|
||||
import io.element.android.tests.testutils.pressBack
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import io.element.android.tests.testutils.setSafeContent
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PinnedMessagesListViewTest {
|
||||
class PinnedMessagesListViewTest : RobolectricTest() {
|
||||
@Test
|
||||
fun `clicking on back calls the expected callback`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<PinnedMessagesListEvent>(expectEvents = false)
|
||||
@@ -98,6 +97,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setPinnedMessagesListView(
|
||||
state: PinnedMessagesListState,
|
||||
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||
onEventClick: (event: TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onGalleryItemClick: (event: TimelineItem.Event, index: Int) -> Unit = EnsureNeverCalledWithTwoParams(),
|
||||
onUserDataClick: (MatrixUser) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onLinkClick: (Link) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onLinkLongClick: (Link) -> Unit = EnsureNeverCalledWithParam(),
|
||||
@@ -107,6 +107,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setPinnedMessagesListView(
|
||||
state = state,
|
||||
onBackClick = onBackClick,
|
||||
onEventClick = onEventClick,
|
||||
onGalleryItemClick = onGalleryItemClick,
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
|
||||
+2
-4
@@ -21,12 +21,10 @@ import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class DefaultHtmlConverterProviderTest {
|
||||
class DefaultHtmlConverterProviderTest : RobolectricTest() {
|
||||
private val provider = DefaultHtmlConverterProvider(
|
||||
mentionSpanProvider = MentionSpanProvider(
|
||||
permalinkParser = FakePermalinkParser(),
|
||||
|
||||
+549
@@ -28,6 +28,7 @@ import io.element.android.features.poll.api.actions.SendPollResponseAction
|
||||
import io.element.android.features.poll.test.actions.FakeEndPollAction
|
||||
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
|
||||
import io.element.android.features.roomcall.api.aStandByCallState
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
@@ -43,6 +44,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
||||
@@ -54,17 +56,20 @@ import io.element.android.libraries.matrix.test.A_UNIQUE_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.matrix.test.timeline.aMessageContent
|
||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||
import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.awaitLastSequentialItem
|
||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
import io.element.android.tests.testutils.consumeItemsUntilTimeout
|
||||
import io.element.android.tests.testutils.lambda.any
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
@@ -366,6 +371,500 @@ class TimelinePresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - jumpToUnread is InWindow at the read marker virtual item index`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = timeline,
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.JumpToUnread.key to true)),
|
||||
)
|
||||
presenter.test {
|
||||
awaitFirstItem()
|
||||
// SDK delivers items oldest-first; the factory reverses so output index 0 is the newest.
|
||||
// After processing: [msg-newest, membership, msg-2, read-marker, msg-old]
|
||||
timelineItems.emit(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(UniqueId("msg-old"), anEventTimelineItem(content = aMessageContent())),
|
||||
MatrixTimelineItem.Virtual(UniqueId("read-marker"), VirtualTimelineItem.ReadMarker),
|
||||
MatrixTimelineItem.Event(UniqueId("msg-2"), anEventTimelineItem(content = aMessageContent())),
|
||||
MatrixTimelineItem.Event(UniqueId("membership"), anEventTimelineItem(content = aRoomMembershipContent())),
|
||||
MatrixTimelineItem.Event(UniqueId("msg-newest"), anEventTimelineItem(content = aMessageContent())),
|
||||
)
|
||||
)
|
||||
consumeItemsUntilPredicate { it.jumpToUnread is JumpToUnreadState.InWindow }.last().also { state ->
|
||||
assertThat(state.jumpToUnread).isEqualTo(JumpToUnreadState.InWindow(index = 3))
|
||||
assertThat(state.displayJumpToUnread).isTrue()
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - jumpToUnread is Hidden when no read marker and no fullyReadEventId`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = timeline,
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.JumpToUnread.key to true)),
|
||||
)
|
||||
presenter.test {
|
||||
awaitFirstItem()
|
||||
timelineItems.emit(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())),
|
||||
MatrixTimelineItem.Event(UniqueId("2"), anEventTimelineItem(content = aMessageContent())),
|
||||
)
|
||||
)
|
||||
consumeItemsUntilPredicate {
|
||||
it.timelineItems.size == 2 && it.jumpToUnread == JumpToUnreadState.Hidden
|
||||
}.last().also { state ->
|
||||
assertThat(state.jumpToUnread).isEqualTo(JumpToUnreadState.Hidden)
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - jumpToUnread is Hidden when JumpToUnread feature flag is disabled`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = timeline,
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.JumpToUnread.key to false)),
|
||||
)
|
||||
presenter.test {
|
||||
awaitFirstItem()
|
||||
timelineItems.emit(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(UniqueId("msg-old"), anEventTimelineItem(content = aMessageContent())),
|
||||
MatrixTimelineItem.Virtual(UniqueId("read-marker"), VirtualTimelineItem.ReadMarker),
|
||||
MatrixTimelineItem.Event(UniqueId("msg-newest"), anEventTimelineItem(content = aMessageContent())),
|
||||
)
|
||||
)
|
||||
consumeItemsUntilPredicate { it.timelineItems.size == 3 }.last().also { state ->
|
||||
assertThat(state.displayJumpToUnread).isFalse()
|
||||
assertThat(state.jumpToUnread).isEqualTo(JumpToUnreadState.Hidden)
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - newEventState becomes FromOther when an event from another user arrives`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||
val presenter = createTimelinePresenter(timeline)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.newEventState).isEqualTo(NewEventState.None)
|
||||
// Seed prevMostRecentItemId so subsequent emissions count as new events.
|
||||
timelineItems.emit(
|
||||
listOf(MatrixTimelineItem.Event(UniqueId("seed"), anEventTimelineItem(content = aMessageContent())))
|
||||
)
|
||||
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
|
||||
timelineItems.getAndUpdate { items ->
|
||||
items + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())))
|
||||
}
|
||||
consumeItemsUntilPredicate { it.newEventState == NewEventState.FromOther }.last().also { state ->
|
||||
assertThat(state.newEventState).isEqualTo(NewEventState.FromOther)
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - newEventState resets to None on OnScrollFinished firstIndex 0`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(
|
||||
timelineItems = timelineItems,
|
||||
markAsReadResult = { Result.success(Unit) },
|
||||
)
|
||||
val presenter = createTimelinePresenter(timeline)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
timelineItems.emit(
|
||||
listOf(MatrixTimelineItem.Event(UniqueId("seed"), anEventTimelineItem(content = aMessageContent())))
|
||||
)
|
||||
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
|
||||
timelineItems.getAndUpdate { items ->
|
||||
items + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())))
|
||||
}
|
||||
consumeItemsUntilPredicate { it.newEventState == NewEventState.FromOther }
|
||||
initialState.eventSink.invoke(TimelineEvent.OnScrollFinished(0))
|
||||
consumeItemsUntilPredicate { it.newEventState == NewEventState.None }.last().also { state ->
|
||||
assertThat(state.newEventState).isEqualTo(NewEventState.None)
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - newEventState transitions to FromMe when latest event is from me`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||
val presenter = createTimelinePresenter(timeline)
|
||||
presenter.test {
|
||||
awaitFirstItem()
|
||||
timelineItems.emit(
|
||||
listOf(MatrixTimelineItem.Event(UniqueId("seed"), anEventTimelineItem(content = aMessageContent())))
|
||||
)
|
||||
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
|
||||
// First, an event from another user moves us to FromOther.
|
||||
timelineItems.getAndUpdate { items ->
|
||||
items + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())))
|
||||
}
|
||||
consumeItemsUntilPredicate { it.newEventState == NewEventState.FromOther }
|
||||
// Then the local user sends a message: state moves to FromMe.
|
||||
timelineItems.getAndUpdate { items ->
|
||||
items + listOf(
|
||||
MatrixTimelineItem.Event(UniqueId("2"), anEventTimelineItem(content = aMessageContent(), isOwn = true)),
|
||||
)
|
||||
}
|
||||
consumeItemsUntilPredicate { it.newEventState == NewEventState.FromMe }.last().also { state ->
|
||||
assertThat(state.newEventState).isEqualTo(NewEventState.FromMe)
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - newEventState does not reset on OnScrollFinished firstIndex other than 0`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||
val presenter = createTimelinePresenter(timeline)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
timelineItems.emit(
|
||||
listOf(MatrixTimelineItem.Event(UniqueId("seed"), anEventTimelineItem(content = aMessageContent())))
|
||||
)
|
||||
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
|
||||
timelineItems.getAndUpdate { items ->
|
||||
items + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())))
|
||||
}
|
||||
consumeItemsUntilPredicate { it.newEventState == NewEventState.FromOther }
|
||||
// Scrolling stops above the bottom: state must NOT reset.
|
||||
initialState.eventSink.invoke(TimelineEvent.OnScrollFinished(5))
|
||||
advanceUntilIdle()
|
||||
val drained = consumeItemsUntilTimeout()
|
||||
assertThat(drained.any { it.newEventState == NewEventState.None }).isFalse()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - newEventState stays None for events with PAGINATION origin`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||
val presenter = createTimelinePresenter(timeline)
|
||||
presenter.test {
|
||||
awaitFirstItem()
|
||||
timelineItems.emit(
|
||||
listOf(MatrixTimelineItem.Event(UniqueId("seed"), anEventTimelineItem(content = aMessageContent())))
|
||||
)
|
||||
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
|
||||
// A back-paginated event arrives. It should not flip newEventState.
|
||||
timelineItems.getAndUpdate { items ->
|
||||
items + listOf(
|
||||
MatrixTimelineItem.Event(
|
||||
UniqueId("paginated"),
|
||||
anEventTimelineItem(content = aMessageContent()).copy(origin = TimelineItemEventOrigin.PAGINATION),
|
||||
)
|
||||
)
|
||||
}
|
||||
consumeItemsUntilPredicate { it.timelineItems.size == 2 }.last().also { state ->
|
||||
assertThat(state.newEventState).isEqualTo(NewEventState.None)
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - jumpToUnread is InWindow at index 0 when the read marker is the only item`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = timeline,
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.JumpToUnread.key to true)),
|
||||
)
|
||||
presenter.test {
|
||||
awaitFirstItem()
|
||||
timelineItems.emit(
|
||||
listOf(MatrixTimelineItem.Virtual(UniqueId("read-marker"), VirtualTimelineItem.ReadMarker))
|
||||
)
|
||||
consumeItemsUntilPredicate { it.jumpToUnread is JumpToUnreadState.InWindow }.last().also { state ->
|
||||
assertThat(state.jumpToUnread).isEqualTo(JumpToUnreadState.InWindow(index = 0))
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - jumpToUnread is OutOfWindow when fullyReadEventId is set but not in the loaded window`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||
val fullyReadEventId = EventId("\$older-than-loaded-window")
|
||||
val room = FakeJoinedRoom(
|
||||
liveTimeline = timeline,
|
||||
baseRoom = FakeBaseRoom(
|
||||
roomPermissions = roomPermissions(),
|
||||
// There is genuinely unread *displayable* content, and the marker event isn't loaded
|
||||
// (isEventLoaded defaults to false), so the FAB targets the out-of-window marker.
|
||||
initialRoomInfo = aRoomInfo(fullyReadEventId = fullyReadEventId, numUnreadMessages = 1),
|
||||
),
|
||||
)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = timeline,
|
||||
room = room,
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.JumpToUnread.key to true)),
|
||||
)
|
||||
presenter.test {
|
||||
awaitFirstItem()
|
||||
// Loaded items don't include the fullyReadEventId and the SDK didn't materialise a ReadMarker.
|
||||
timelineItems.emit(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())),
|
||||
MatrixTimelineItem.Event(UniqueId("2"), anEventTimelineItem(eventId = AN_EVENT_ID_2, content = aMessageContent())),
|
||||
)
|
||||
)
|
||||
consumeItemsUntilPredicate { it.jumpToUnread is JumpToUnreadState.OutOfWindow }.last().also { state ->
|
||||
assertThat(state.jumpToUnread).isEqualTo(JumpToUnreadState.OutOfWindow(eventId = fullyReadEventId))
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - jumpToUnread is Hidden when the marker event is loaded in the window but not displayed`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
// The marker event is in the loaded window (e.g. a state/filtered event) but never rendered.
|
||||
val fullyReadEventId = EventId("\$loaded-but-not-displayed")
|
||||
val timeline = FakeTimeline(timelineItems = timelineItems).apply {
|
||||
isEventLoadedLambda = { it == fullyReadEventId }
|
||||
}
|
||||
val room = FakeJoinedRoom(
|
||||
liveTimeline = timeline,
|
||||
baseRoom = FakeBaseRoom(
|
||||
roomPermissions = roomPermissions(),
|
||||
initialRoomInfo = aRoomInfo(fullyReadEventId = fullyReadEventId, numUnreadMessages = 1),
|
||||
),
|
||||
)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = timeline,
|
||||
room = room,
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.JumpToUnread.key to true)),
|
||||
)
|
||||
presenter.test {
|
||||
awaitFirstItem()
|
||||
// Displayed items don't contain the marker event, but the SDK reports it as loaded.
|
||||
timelineItems.emit(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())),
|
||||
MatrixTimelineItem.Event(UniqueId("2"), anEventTimelineItem(eventId = AN_EVENT_ID_2, content = aMessageContent())),
|
||||
)
|
||||
)
|
||||
advanceUntilIdle()
|
||||
// It must never be OutOfWindow — there is nothing displayable to jump to.
|
||||
val drained = consumeItemsUntilTimeout()
|
||||
assertThat(drained.any { it.jumpToUnread is JumpToUnreadState.OutOfWindow }).isFalse()
|
||||
assertThat(drained.last().jumpToUnread).isEqualTo(JumpToUnreadState.Hidden)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - jumpToUnread is Hidden when out of window but there are no unread messages`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val fullyReadEventId = EventId("\$older-than-loaded-window")
|
||||
// isEventLoaded defaults to false (genuinely out of window), but numUnreadMessages is 0.
|
||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||
val room = FakeJoinedRoom(
|
||||
liveTimeline = timeline,
|
||||
baseRoom = FakeBaseRoom(
|
||||
roomPermissions = roomPermissions(),
|
||||
initialRoomInfo = aRoomInfo(fullyReadEventId = fullyReadEventId, numUnreadMessages = 0),
|
||||
),
|
||||
)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = timeline,
|
||||
room = room,
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.JumpToUnread.key to true)),
|
||||
)
|
||||
presenter.test {
|
||||
awaitFirstItem()
|
||||
timelineItems.emit(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())),
|
||||
MatrixTimelineItem.Event(UniqueId("2"), anEventTimelineItem(eventId = AN_EVENT_ID_2, content = aMessageContent())),
|
||||
)
|
||||
)
|
||||
advanceUntilIdle()
|
||||
val drained = consumeItemsUntilTimeout()
|
||||
assertThat(drained.any { it.jumpToUnread != JumpToUnreadState.Hidden }).isFalse()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - jumpToUnread is Hidden when fullyReadEventId IS in the loaded window`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||
val room = FakeJoinedRoom(
|
||||
liveTimeline = timeline,
|
||||
baseRoom = FakeBaseRoom(
|
||||
roomPermissions = roomPermissions(),
|
||||
// The user is caught up: the marker event is loaded, but the SDK didn't insert a
|
||||
// virtual ReadMarker because there are no items newer than it.
|
||||
initialRoomInfo = aRoomInfo(fullyReadEventId = AN_EVENT_ID),
|
||||
),
|
||||
)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = timeline,
|
||||
room = room,
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.JumpToUnread.key to true)),
|
||||
)
|
||||
presenter.test {
|
||||
awaitFirstItem()
|
||||
// A loaded item has eventId == AN_EVENT_ID (default of anEventTimelineItem).
|
||||
timelineItems.emit(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())),
|
||||
)
|
||||
)
|
||||
consumeItemsUntilPredicate { it.timelineItems.size == 1 }.last().also { state ->
|
||||
assertThat(state.jumpToUnread).isEqualTo(JumpToUnreadState.Hidden)
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - jumpToUnread is Hidden when fullyReadEventId is set but the timeline is empty`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||
val room = FakeJoinedRoom(
|
||||
liveTimeline = timeline,
|
||||
baseRoom = FakeBaseRoom(
|
||||
roomPermissions = roomPermissions(),
|
||||
initialRoomInfo = aRoomInfo(fullyReadEventId = AN_EVENT_ID),
|
||||
),
|
||||
)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = timeline,
|
||||
room = room,
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.JumpToUnread.key to true)),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
// Without any timeline items, the FAB must stay hidden — the user is mid-load.
|
||||
assertThat(initialState.jumpToUnread).isEqualTo(JumpToUnreadState.Hidden)
|
||||
advanceUntilIdle()
|
||||
val drained = consumeItemsUntilTimeout()
|
||||
assertThat(drained.any { it.jumpToUnread != JumpToUnreadState.Hidden }).isFalse()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - jumpToUnread is Hidden when fullyReadEventId is set but the feature flag is off`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||
val room = FakeJoinedRoom(
|
||||
liveTimeline = timeline,
|
||||
baseRoom = FakeBaseRoom(
|
||||
roomPermissions = roomPermissions(),
|
||||
initialRoomInfo = aRoomInfo(fullyReadEventId = AN_EVENT_ID),
|
||||
),
|
||||
)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = timeline,
|
||||
room = room,
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.JumpToUnread.key to false)),
|
||||
)
|
||||
presenter.test {
|
||||
awaitFirstItem()
|
||||
timelineItems.emit(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(content = aMessageContent())),
|
||||
)
|
||||
)
|
||||
consumeItemsUntilPredicate { it.timelineItems.size == 1 }.last().also { state ->
|
||||
assertThat(state.jumpToUnread).isEqualTo(JumpToUnreadState.Hidden)
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - jumpToUnread prefers InWindow when both a virtual marker and fullyReadEventId are present`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(timelineItems = timelineItems)
|
||||
val room = FakeJoinedRoom(
|
||||
liveTimeline = timeline,
|
||||
baseRoom = FakeBaseRoom(
|
||||
roomPermissions = roomPermissions(),
|
||||
initialRoomInfo = aRoomInfo(fullyReadEventId = AN_EVENT_ID),
|
||||
),
|
||||
)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = timeline,
|
||||
room = room,
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.JumpToUnread.key to true)),
|
||||
)
|
||||
presenter.test {
|
||||
awaitFirstItem()
|
||||
timelineItems.emit(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(UniqueId("msg-old"), anEventTimelineItem(content = aMessageContent())),
|
||||
MatrixTimelineItem.Virtual(UniqueId("read-marker"), VirtualTimelineItem.ReadMarker),
|
||||
MatrixTimelineItem.Event(UniqueId("msg-newest"), anEventTimelineItem(content = aMessageContent())),
|
||||
)
|
||||
)
|
||||
consumeItemsUntilPredicate { it.jumpToUnread is JumpToUnreadState.InWindow }.last().also { state ->
|
||||
assertThat(state.jumpToUnread).isInstanceOf(JumpToUnreadState.InWindow::class.java)
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - jumpToUnread hides eagerly after MarkAllAsRead even before a new RoomInfo arrives`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
val timeline = FakeTimeline(
|
||||
timelineItems = timelineItems,
|
||||
getLatestEventIdResult = { Result.success(AN_EVENT_ID_2) },
|
||||
)
|
||||
val fullyReadEventId = EventId("\$older-than-loaded-window")
|
||||
val room = FakeJoinedRoom(
|
||||
liveTimeline = timeline,
|
||||
baseRoom = FakeBaseRoom(
|
||||
roomPermissions = roomPermissions(),
|
||||
initialRoomInfo = aRoomInfo(fullyReadEventId = fullyReadEventId, numUnreadMessages = 1),
|
||||
),
|
||||
)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = timeline,
|
||||
room = room,
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.JumpToUnread.key to true)),
|
||||
)
|
||||
presenter.test {
|
||||
awaitFirstItem()
|
||||
timelineItems.emit(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())),
|
||||
)
|
||||
)
|
||||
val outOfWindow = consumeItemsUntilPredicate { it.jumpToUnread is JumpToUnreadState.OutOfWindow }.last()
|
||||
assertThat(outOfWindow.jumpToUnread).isEqualTo(JumpToUnreadState.OutOfWindow(eventId = fullyReadEventId))
|
||||
|
||||
outOfWindow.eventSink(TimelineEvent.MarkAllAsRead)
|
||||
// RoomInfo is intentionally NOT updated — eager hide must fire on the await alone.
|
||||
val afterMark = consumeItemsUntilPredicate { it.jumpToUnread == JumpToUnreadState.Hidden }.last()
|
||||
assertThat(afterMark.jumpToUnread).isEqualTo(JumpToUnreadState.Hidden)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - reaction ordering`() = runTest {
|
||||
val timelineItems = MutableStateFlow(emptyList<MatrixTimelineItem>())
|
||||
@@ -975,6 +1474,54 @@ class TimelinePresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - MarkAllAsRead invokes markAsFullyRead with latest event id`() = runTest {
|
||||
val markAsFullyReadRecorder = lambdaRecorder<RoomId, EventId, Unit> { _, _ -> }
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = FakeTimeline(getLatestEventIdResult = { Result.success(AN_EVENT_ID) }),
|
||||
markAsFullyRead = FakeMarkAsFullyRead(markAsFullyReadRecorder),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink(TimelineEvent.MarkAllAsRead)
|
||||
advanceUntilIdle()
|
||||
markAsFullyReadRecorder.assertions().isCalledOnce().with(value(A_ROOM_ID), value(AN_EVENT_ID))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - MarkAllAsRead does not invoke markAsFullyRead when latest event id lookup fails`() = runTest {
|
||||
val markAsFullyReadRecorder = lambdaRecorder<RoomId, EventId, Unit> { _, _ -> }
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = FakeTimeline(getLatestEventIdResult = { Result.failure(RuntimeException("boom")) }),
|
||||
markAsFullyRead = FakeMarkAsFullyRead(markAsFullyReadRecorder),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink(TimelineEvent.MarkAllAsRead)
|
||||
advanceUntilIdle()
|
||||
markAsFullyReadRecorder.assertions().isNeverCalled()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - MarkAllAsRead does not invoke markAsFullyRead when there is no latest event`() = runTest {
|
||||
val markAsFullyReadRecorder = lambdaRecorder<RoomId, EventId, Unit> { _, _ -> }
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = FakeTimeline(getLatestEventIdResult = { Result.success(null) }),
|
||||
markAsFullyRead = FakeMarkAsFullyRead(markAsFullyReadRecorder),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink(TimelineEvent.MarkAllAsRead)
|
||||
advanceUntilIdle()
|
||||
markAsFullyReadRecorder.assertions().isNeverCalled()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
||||
return awaitItem()
|
||||
}
|
||||
@@ -1014,6 +1561,7 @@ class TimelinePresenterTest {
|
||||
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
|
||||
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
|
||||
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
|
||||
markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead { _, _ -> },
|
||||
): TimelinePresenter {
|
||||
return TimelinePresenter(
|
||||
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
|
||||
@@ -1033,6 +1581,7 @@ class TimelinePresenterTest {
|
||||
featureFlagService = featureFlagService,
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
liveLocationShareManager = liveLocationShareManager,
|
||||
markAsFullyRead = markAsFullyRead,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -18,7 +18,6 @@ import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollToIndex
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.features.messages.impl.timeline.components.MessageShieldData
|
||||
import io.element.android.features.messages.impl.timeline.components.aCriticalShield
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
@@ -37,16 +36,15 @@ import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import io.element.android.tests.testutils.setSafeContent
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TimelineViewTest {
|
||||
class TimelineViewTest : RobolectricTest() {
|
||||
@Test
|
||||
fun `reaching the end of the timeline with more events to load emits a LoadMore event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
@@ -218,6 +216,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setTimelineView(
|
||||
onReactionLongClick: (emoji: String, TimelineItem.Event) -> Unit = EnsureNeverCalledWithTwoParams(),
|
||||
onMoreReactionsClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onReadReceiptClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onGalleryItemClick: (TimelineItem.Event, Int) -> Unit = EnsureNeverCalledWithTwoParams(),
|
||||
forceJumpToBottomVisibility: Boolean = false,
|
||||
) {
|
||||
setSafeContent(clearAndroidUiDispatcher = true) {
|
||||
@@ -233,6 +232,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setTimelineView(
|
||||
onReactionLongClick = onReactionLongClick,
|
||||
onMoreReactionsClick = onMoreReactionsClick,
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
onGalleryItemClick = onGalleryItemClick,
|
||||
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
||||
)
|
||||
}
|
||||
|
||||
+2
-4
@@ -13,7 +13,6 @@ import androidx.compose.runtime.InternalComposeApi
|
||||
import androidx.compose.runtime.currentComposer
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
@@ -25,6 +24,7 @@ import io.element.android.emojibasebindings.EmojibaseCategory
|
||||
import io.element.android.emojibasebindings.EmojibaseStore
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
@@ -33,10 +33,8 @@ import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class EmojiPickerPresenterTest {
|
||||
class EmojiPickerPresenterTest : RobolectricTest() {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
|
||||
+2
-4
@@ -15,7 +15,6 @@ import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
@@ -23,11 +22,10 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.pressTag
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TimelineItemPollViewTest {
|
||||
class TimelineItemPollViewTest : RobolectricTest() {
|
||||
@Test
|
||||
fun `answering a poll with first answer should emit a PollAnswerSelected event`() {
|
||||
testAnswer(answerIndex = 0)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user