mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-04 01:19:33 +00:00
Content scanner (Element Pro): scan timeline events for unsafe content (#7159)
* Simplify how event content with captions are displayed The caption component will now be added in `TimelineItemEventContentView` instead of separately in each `TimelineItem__View` component * Make sure `CoilMediaFetcher` methods to fetch media throw errors We need to catch them later to check if the content scanner marked some thumbnails or medias as invalid/unsafe in `AsyncImage` instances * Add a `ContentScannerService` that allows us to manually scan a list of medias in an event and then update and store the results The results take the form of `ContentValidationValue`, stored in a `ContentValidationState` instance tied to the event * Create `EventContentValidationCache` to keep the `ContentValidationStates` for events in a room * Expose `EventContentValidationCache` to Compose using `LocalEventContentValidationState` Also add a couple of helper functions to avoid having to manually remember the states * Use the new components to display invalid content in the timeline items * Add `blurhash` to most media items so we can use it while validating the thumbnails/medias * Fix custom layout issues with screenshot and UI tests * Also check the validation state for replied-to events in the composer * Fix and add tests * Handle unrecoverable content validation errors too: display a 'not found' UI * Improve the performance of `EqualWidthColumn` for the single item case * Update screenshots --------- Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
committed by
GitHub
parent
56cf00560e
commit
0d03472c60
@@ -62,6 +62,7 @@ dependencies {
|
||||
implementation(projects.libraries.uiUtils)
|
||||
implementation(projects.libraries.testtags)
|
||||
implementation(projects.features.networkmonitor.api)
|
||||
implementation(projects.features.contentscanner.api)
|
||||
implementation(projects.services.analytics.compose)
|
||||
implementation(projects.services.appnavstate.api)
|
||||
implementation(projects.services.toolbox.api)
|
||||
|
||||
+6
@@ -57,6 +57,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.blurHash
|
||||
import io.element.android.features.messages.impl.timeline.model.event.duration
|
||||
import io.element.android.features.poll.api.create.CreatePollEntryPoint
|
||||
import io.element.android.features.poll.api.create.CreatePollMode
|
||||
@@ -155,6 +156,7 @@ class MessagesFlowNode(
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val canUseOverlay: Boolean,
|
||||
val blurHash: String?,
|
||||
) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
@@ -377,6 +379,7 @@ class MessagesFlowNode(
|
||||
mediaInfo = navTarget.mediaInfo,
|
||||
mediaSource = navTarget.mediaSource,
|
||||
thumbnailSource = navTarget.thumbnailSource,
|
||||
blurHash = navTarget.blurHash,
|
||||
)
|
||||
val callback = object : MediaViewerEntryPoint.Callback {
|
||||
override fun onDone() {
|
||||
@@ -828,6 +831,7 @@ class MessagesFlowNode(
|
||||
mediaSource = galleryItem.mediaSource,
|
||||
thumbnailSource = galleryItem.thumbnailSource,
|
||||
type = galleryItem.type.toMediaViewerType(),
|
||||
blurHash = galleryItem.blurhash,
|
||||
)
|
||||
}.reversed()
|
||||
NavTarget.GalleryViewer(
|
||||
@@ -861,6 +865,7 @@ class MessagesFlowNode(
|
||||
mediaSource = attachment.mediaSource,
|
||||
thumbnailSource = attachment.thumbnailSource,
|
||||
type = GalleryItemData.Type.File,
|
||||
blurHash = null,
|
||||
)
|
||||
}.reversed()
|
||||
NavTarget.GalleryViewer(
|
||||
@@ -920,6 +925,7 @@ class MessagesFlowNode(
|
||||
mediaSource = mediaSource,
|
||||
thumbnailSource = thumbnailSource,
|
||||
canUseOverlay = canUseOverlay,
|
||||
blurHash = content.blurHash(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -66,6 +66,8 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.room.alias.matches
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.model.getBestName
|
||||
import io.element.android.libraries.mediaplayer.api.MediaPlayer
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@@ -97,6 +99,7 @@ class MessagesNode(
|
||||
private val permalinkParser: PermalinkParser,
|
||||
private val knockRequestsBannerRenderer: KnockRequestsBannerRenderer,
|
||||
private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
|
||||
private val eventContentValidationCache: EventContentValidationCache,
|
||||
) : Node(buildContext, plugins = plugins), MessagesNavigator {
|
||||
data class Inputs(
|
||||
val focusedEventId: EventId?,
|
||||
@@ -261,6 +264,7 @@ class MessagesNode(
|
||||
val canUseOverlay = !isTalkbackActive() && !hasExternalKeyboard()
|
||||
CompositionLocalProvider(
|
||||
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
|
||||
LocalEventContentValidationState provides eventContentValidationCache,
|
||||
) {
|
||||
val state = presenter.present()
|
||||
|
||||
|
||||
+7
@@ -123,6 +123,8 @@ import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
@@ -148,6 +150,8 @@ fun MessagesView(
|
||||
forceJumpToBottomVisibility: Boolean = false,
|
||||
knockRequestsBannerView: @Composable () -> Unit,
|
||||
) {
|
||||
val eventContentValidationState = LocalEventContentValidationState.current
|
||||
|
||||
OnLifecycleEvent { _, event ->
|
||||
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.LifecycleEvent(event))
|
||||
}
|
||||
@@ -170,6 +174,9 @@ fun MessagesView(
|
||||
|
||||
fun onContentClick(event: TimelineItem.Event) {
|
||||
Timber.v("onMessageClick= ${event.id}")
|
||||
val eventId = event.eventId ?: return
|
||||
if (eventContentValidationState[eventId].getCurrentOverallState() != ContentValidationValue.Valid) return
|
||||
|
||||
val hideKeyboard = onEventContentClick(state.timelineState.isLive, event)
|
||||
if (hideKeyboard) {
|
||||
localView.hideKeyboard()
|
||||
|
||||
+12
@@ -31,6 +31,7 @@ import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import im.vector.app.features.analytics.plan.Composer
|
||||
import im.vector.app.features.analytics.plan.Interaction
|
||||
import io.element.android.features.contentscanner.api.ContentScannerService
|
||||
import io.element.android.features.location.api.LocationService
|
||||
import io.element.android.features.messages.impl.MessagesNavigator
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
@@ -61,8 +62,12 @@ import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
|
||||
import io.element.android.libraries.matrix.api.room.getDirectRoomMember
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.use
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineException
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.mediaSources
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.content
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.eventId
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.map
|
||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
|
||||
@@ -135,6 +140,8 @@ class MessageComposerPresenter(
|
||||
private val notificationConversationService: NotificationConversationService,
|
||||
private val slashCommandService: SlashCommandService,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val contentScannerService: ContentScannerService,
|
||||
private val contentValidationCache: EventContentValidationCache,
|
||||
) : Presenter<MessageComposerState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
@@ -848,6 +855,11 @@ class MessageComposerPresenter(
|
||||
setText(newComposerMode.content, markdownTextEditorState, richTextEditorState, requestFocus = true)
|
||||
}
|
||||
else -> {
|
||||
if (currentComposerMode is MessageComposerMode.Reply) {
|
||||
val mediaSources = currentComposerMode.replyToDetails.content()?.mediaSources() ?: return@launch
|
||||
val contentValidationState = contentValidationCache[currentComposerMode.replyToDetails.eventId()]
|
||||
contentScannerService.scan(currentComposerMode.replyToDetails.eventId(), mediaSources, contentValidationState)
|
||||
}
|
||||
// When coming from edit, just clear the composer as it'd be weird to reset a volatile draft in this scenario.
|
||||
if (currentComposerMode.isEditing) {
|
||||
setText("", markdownTextEditorState, richTextEditorState)
|
||||
|
||||
+2
-3
@@ -36,7 +36,6 @@ import io.element.android.features.messages.impl.timeline.components.event.Timel
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.features.poll.api.pollcontent.PollTitleView
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||
@@ -290,9 +289,9 @@ private fun TimelineItemEventContentViewWrapper(
|
||||
)
|
||||
} else {
|
||||
TimelineItemEventContentView(
|
||||
eventId = event.eventId,
|
||||
content = event.content,
|
||||
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
|
||||
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
onGalleryItemClick = onGalleryItemClick,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
|
||||
+8
@@ -13,7 +13,9 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import kotlin.time.Duration
|
||||
|
||||
sealed interface TimelineEvent {
|
||||
@@ -61,4 +63,10 @@ sealed interface TimelineEvent {
|
||||
) : TimelineItemPollEvent
|
||||
|
||||
data object StopLiveLocationShare : TimelineItemEvent
|
||||
|
||||
data class ValidateMedia(
|
||||
val eventId: EventId,
|
||||
val mediaSources: List<MediaSource>,
|
||||
val validationState: ContentValidationState
|
||||
) : TimelineItemEvent
|
||||
}
|
||||
|
||||
+10
-1
@@ -35,6 +35,8 @@ import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.features.messages.impl.typing.TypingNotificationState
|
||||
import io.element.android.features.messages.impl.userEventPermissions
|
||||
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
|
||||
@@ -64,6 +66,7 @@ import io.element.android.services.analytics.api.finishLongRunningTransaction
|
||||
import io.element.android.services.analyticsproviders.api.AnalyticsUserData
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@@ -98,6 +101,7 @@ class TimelinePresenter(
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val liveLocationShareManager: ActiveLiveLocationShareManager,
|
||||
private val markAsFullyRead: MarkAsFullyRead,
|
||||
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
|
||||
) : Presenter<TimelineState> {
|
||||
private val tag = "TimelinePresenter"
|
||||
|
||||
@@ -157,6 +161,8 @@ class TimelinePresenter(
|
||||
value = featureFlagService.isFeatureEnabled(FeatureFlags.JumpToUnread)
|
||||
}
|
||||
|
||||
val timelineProtectionState = timelineProtectionPresenter.present()
|
||||
|
||||
fun handleEvent(event: TimelineEvent) {
|
||||
when (event) {
|
||||
is TimelineEvent.LoadMore -> {
|
||||
@@ -252,6 +258,9 @@ class TimelinePresenter(
|
||||
focusedEventId = event.focusedEvent,
|
||||
)
|
||||
}
|
||||
is TimelineEvent.ValidateMedia -> {
|
||||
timelineProtectionState.eventSink(TimelineProtectionEvent.ValidateContent(event.eventId, event.mediaSources, event.validationState))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +268,7 @@ class TimelinePresenter(
|
||||
timelineItemsFactory.timelineItems
|
||||
.onEach { newTimelineItems ->
|
||||
timelineItemIndexer.process(newTimelineItems)
|
||||
timelineItems = newTimelineItems
|
||||
timelineItems = newTimelineItems.toImmutableList()
|
||||
|
||||
analyticsService.run {
|
||||
finishLongRunningTransaction(DisplayFirstTimelineItems)
|
||||
|
||||
+2
-1
@@ -11,5 +11,6 @@ package io.element.android.features.messages.impl.timeline.components
|
||||
enum class ContentPadding {
|
||||
Textual,
|
||||
Media,
|
||||
CaptionedMedia
|
||||
CaptionedMedia,
|
||||
InvalidContent,
|
||||
}
|
||||
|
||||
+12
-1
@@ -19,7 +19,9 @@ import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
@@ -27,6 +29,8 @@ import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.drawOutline
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.layer.CompositingStrategy
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
@@ -62,6 +66,8 @@ fun MessageEventBubble(
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
customBackgroundColor: Color? = null,
|
||||
borderColor: Color? = null,
|
||||
content: @Composable BoxScope.() -> Unit = {},
|
||||
) {
|
||||
val clickableModifier = if (isTalkbackActive()) {
|
||||
@@ -79,10 +85,12 @@ fun MessageEventBubble(
|
||||
|
||||
val cutTopStart = state.cutTopStart
|
||||
// Ignore state.isHighlighted for now, we need a design decision on it.
|
||||
val backgroundBubbleColor = MessageEventBubbleDefaults.backgroundBubbleColor(state.isMine)
|
||||
val backgroundBubbleColor by rememberUpdatedState(customBackgroundColor ?: MessageEventBubbleDefaults.backgroundBubbleColor(state.isMine))
|
||||
val bubbleShape = remember(state) { MessageEventBubbleDefaults.shape(state.cutTopStart, state.groupPosition, state.isMine) }
|
||||
val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx()
|
||||
val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx()
|
||||
|
||||
val updatedBorderColor by rememberUpdatedState(borderColor)
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.drawWithCache {
|
||||
@@ -103,6 +111,9 @@ fun MessageEventBubble(
|
||||
// Then draw the content on top of it
|
||||
drawContent()
|
||||
|
||||
// Draw border color, if any
|
||||
updatedBorderColor?.let { drawOutline(outline, it, style = Stroke(width = 1.dp.toPx())) }
|
||||
|
||||
// And then clip the top start corner if needed to make room for the avatar
|
||||
if (cutTopStart) {
|
||||
drawCircle(
|
||||
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.features.messages.impl.timeline.components.event.ElementTimelineItemPreview
|
||||
import io.element.android.features.messages.impl.timeline.components.event.aGalleryItem
|
||||
import io.element.android.features.messages.impl.timeline.components.event.aTimelineItemGalleryContent
|
||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAttachmentsContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAudioContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStickerContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.anAttachmentItem
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.LocalEventContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
|
||||
|
||||
private val AN_EVENT_ID = EventId($$"$eventId")
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemImageViewScanningContentPreview() = ElementPreview {
|
||||
val cache = remember {
|
||||
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading)))
|
||||
}
|
||||
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemImageContent()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemStickerViewScanningContentPreview() = ElementPreview {
|
||||
val cache = remember {
|
||||
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading)))
|
||||
}
|
||||
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemStickerContent(aspectRatio = 1.5f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemVideoViewScanningContentPreview() = ElementPreview {
|
||||
val cache = remember {
|
||||
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading)))
|
||||
}
|
||||
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemVideoContent()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemFileViewScanningContentPreview() {
|
||||
ElementTimelineItemPreview {
|
||||
val cache = remember {
|
||||
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading)))
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalEventContentValidationState provides cache
|
||||
) {
|
||||
ATimelineItemEventRow(event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemFileContent()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemAudioViewScanningContentPreview() {
|
||||
ElementTimelineItemPreview {
|
||||
val cache = remember {
|
||||
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading)))
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalEventContentValidationState provides cache
|
||||
) {
|
||||
ATimelineItemEventRow(event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemAudioContent()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemVoiceViewScanningContentPreview() = ElementPreview {
|
||||
val cache = remember {
|
||||
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Loading)))
|
||||
}
|
||||
ElementTimelineItemPreview {
|
||||
CompositionLocalProvider(
|
||||
LocalEventContentValidationState provides cache,
|
||||
LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(),
|
||||
) {
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemVoiceContent()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemScanningContentFailedPreview() = ElementPreview {
|
||||
val cache = remember {
|
||||
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Invalid)))
|
||||
}
|
||||
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ATimelineItemEventRow(event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemImageContent()))
|
||||
ATimelineItemEventRow(event = aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemImageContent(caption = "A caption")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemScanningContentWithInvalidRepliesPreview() = ElementPreview {
|
||||
val cache = remember {
|
||||
InMemoryEventContentValidationCache(initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.Invalid)))
|
||||
}
|
||||
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
content = aTimelineItemTextContent(),
|
||||
inReplyTo = inReplyToInvalidContent(),
|
||||
)
|
||||
)
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
eventId = AN_EVENT_ID,
|
||||
content = aTimelineItemImageContent(),
|
||||
inReplyTo = inReplyToInvalidContent(),
|
||||
)
|
||||
)
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
eventId = AN_EVENT_ID,
|
||||
content = aTimelineItemImageContent(),
|
||||
inReplyTo = inReplyToTextContent(),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemScanningContentWithRepliesFailedPreview() = ElementPreview {
|
||||
val cache = remember {
|
||||
InMemoryEventContentValidationCache(
|
||||
initial = mapOf(AN_EVENT_ID to NoopContentValidationState(ContentValidationValue.UnrecoverableError(IllegalStateException("BOOM"))))
|
||||
)
|
||||
}
|
||||
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
content = aTimelineItemTextContent(),
|
||||
inReplyTo = inReplyToInvalidContent(),
|
||||
)
|
||||
)
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
eventId = AN_EVENT_ID,
|
||||
content = aTimelineItemImageContent(),
|
||||
inReplyTo = inReplyToInvalidContent(),
|
||||
)
|
||||
)
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
eventId = AN_EVENT_ID,
|
||||
content = aTimelineItemImageContent(),
|
||||
inReplyTo = inReplyToTextContent(),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemGalleryViewScanningContentFailedPreview() = ElementPreview {
|
||||
val cache = remember {
|
||||
InMemoryEventContentValidationCache(
|
||||
initial =
|
||||
mapOf(
|
||||
AN_EVENT_ID to DefaultContentValidationState(mapOf(
|
||||
"invalid" to ContentValidationValue.Invalid,
|
||||
"error" to ContentValidationValue.UnrecoverableError(IllegalStateException("BOOM")),
|
||||
"" to ContentValidationValue.Valid
|
||||
))
|
||||
)
|
||||
)
|
||||
}
|
||||
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
eventId = AN_EVENT_ID,
|
||||
content = aTimelineItemGalleryContent(
|
||||
items = listOf(
|
||||
aGalleryItem(),
|
||||
aGalleryItem(mediaSource = MediaSource("invalid", "{}")),
|
||||
aGalleryItem(),
|
||||
aGalleryItem(mediaSource = MediaSource("invalid", "{}")),
|
||||
aGalleryItem(mediaSource = MediaSource("error", "{}")),
|
||||
aGalleryItem(mediaSource = MediaSource("invalid", "{}")),
|
||||
aGalleryItem(),
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemAttachmentsViewScanningContentFailedPreview() = ElementPreview {
|
||||
val cache = remember {
|
||||
InMemoryEventContentValidationCache(
|
||||
initial =
|
||||
mapOf(
|
||||
AN_EVENT_ID to DefaultContentValidationState(mapOf(
|
||||
"invalid" to ContentValidationValue.Invalid,
|
||||
"error" to ContentValidationValue.UnrecoverableError(IllegalStateException("BOOM")),
|
||||
"" to ContentValidationValue.Valid
|
||||
))
|
||||
)
|
||||
)
|
||||
}
|
||||
CompositionLocalProvider(LocalEventContentValidationState provides cache) {
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
eventId = AN_EVENT_ID,
|
||||
content = aTimelineItemAttachmentsContent(
|
||||
attachments = listOf(
|
||||
anAttachmentItem(),
|
||||
anAttachmentItem(mediaSource = MediaSource("invalid", "{}")),
|
||||
anAttachmentItem(),
|
||||
anAttachmentItem(),
|
||||
anAttachmentItem(mediaSource = MediaSource("error", "{}")),
|
||||
anAttachmentItem(mediaSource = MediaSource("invalid", "{}")),
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun inReplyToInvalidContent(): InReplyToDetails.Ready = InReplyToDetails.Ready(
|
||||
eventId = AN_EVENT_ID,
|
||||
senderId = UserId("@sender:matrix.org"),
|
||||
eventContent = MessageContent(
|
||||
body = "A body",
|
||||
inReplyTo = null,
|
||||
isEdited = false,
|
||||
threadInfo = null,
|
||||
type = ImageMessageType(
|
||||
filename = "A file",
|
||||
caption = "A caption",
|
||||
formattedCaption = null,
|
||||
source = MediaSource("", ""),
|
||||
info = null,
|
||||
)
|
||||
),
|
||||
textContent = "A text content",
|
||||
senderProfile = ProfileDetails.Ready(displayName = "Sender", displayNameAmbiguous = false, avatarUrl = null)
|
||||
)
|
||||
|
||||
private fun inReplyToTextContent(): InReplyToDetails.Ready = InReplyToDetails.Ready(
|
||||
eventId = EventId($$"$text_eventId"),
|
||||
senderId = UserId("@sender:matrix.org"),
|
||||
eventContent = MessageContent(
|
||||
body = "A body",
|
||||
inReplyTo = null,
|
||||
isEdited = false,
|
||||
threadInfo = null,
|
||||
type = TextMessageType(
|
||||
body = "A body",
|
||||
formatted = null,
|
||||
)
|
||||
),
|
||||
textContent = "A text content",
|
||||
senderProfile = ProfileDetails.Ready(displayName = "Sender", displayNameAmbiguous = false, avatarUrl = null)
|
||||
)
|
||||
+85
-28
@@ -33,9 +33,12 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.movableContentOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -88,7 +91,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.features.messages.impl.timeline.protection.mustBeProtected
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
@@ -122,9 +124,13 @@ 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.matrix.api.timeline.item.event.getDisplayName
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.mediaSources
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToView
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.content
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.eventId
|
||||
import io.element.android.libraries.matrix.ui.messages.sender.SenderName
|
||||
import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode
|
||||
@@ -176,12 +182,12 @@ fun TimelineItemEventRow(
|
||||
val onContentClick = onEventClick.takeUnless { event.isWholeContentClickable }
|
||||
|
||||
TimelineItemEventContentView(
|
||||
eventId = event.eventId,
|
||||
content = event.content,
|
||||
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
onContentClick = onContentClick,
|
||||
onGalleryItemClick = onGalleryItemClick,
|
||||
onLongClick = onLongClick,
|
||||
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
eventSink = eventSink,
|
||||
@@ -468,6 +474,23 @@ private fun TimelineItemEventRowContent(
|
||||
)
|
||||
}
|
||||
|
||||
val currentContentValidationState by rememberEventContentValidationState(eventId = event.eventId, needsValidation = event.content.isMedia)
|
||||
.collectOverallState()
|
||||
val needsInvalidContentCustomisations =
|
||||
// Gallery events should not apply the custom bubble color, instead each item will apply some custom color if needed
|
||||
event.content !is TimelineItemGalleryContent &&
|
||||
event.content !is TimelineItemAttachmentsContent &&
|
||||
currentContentValidationState.hasError() &&
|
||||
event.content.isMedia
|
||||
|
||||
// If the event has a dangerous media content we need to set custom message bubble background and border colors
|
||||
val themeColors = ElementTheme.colors
|
||||
val (dangerousContentBubbleColor, borderColor) = remember(themeColors.isLight, needsInvalidContentCustomisations, event.content.type) {
|
||||
val background = themeColors.bgCriticalSubtle.takeIf { needsInvalidContentCustomisations }
|
||||
val border = themeColors.borderCriticalSubtle.takeIf { needsInvalidContentCustomisations }
|
||||
background to border
|
||||
}
|
||||
|
||||
// Message bubble
|
||||
val bubbleState = BubbleState(
|
||||
groupPosition = event.groupPosition,
|
||||
@@ -494,6 +517,8 @@ private fun TimelineItemEventRowContent(
|
||||
interactionSource = interactionSource,
|
||||
onClick = onContentClick,
|
||||
onLongClick = onLongClick,
|
||||
customBackgroundColor = dangerousContentBubbleColor,
|
||||
borderColor = borderColor,
|
||||
) {
|
||||
MessageEventBubbleContent(
|
||||
event = event,
|
||||
@@ -747,6 +772,7 @@ private fun MessageEventBubbleContent(
|
||||
}
|
||||
ContentPadding.CaptionedMedia ->
|
||||
Modifier.padding(start = 8.dp, end = 8.dp, top = topPadding, bottom = 8.dp)
|
||||
ContentPadding.InvalidContent -> Modifier.padding(top = topPadding, bottom = 8.dp)
|
||||
}
|
||||
|
||||
val threadDecoration = @Composable {
|
||||
@@ -770,10 +796,13 @@ private fun MessageEventBubbleContent(
|
||||
}
|
||||
|
||||
val inReplyTo = @Composable { inReplyTo: InReplyToDetails ->
|
||||
val currentContentValidationState by rememberEventContentValidationState(eventId = inReplyTo.eventId(), eventContent = inReplyTo.content())
|
||||
.collectOverallState()
|
||||
val topPadding = if (showThreadDecoration) 0.dp else 8.dp
|
||||
val shape = RoundedCornerShape(6.dp)
|
||||
val inReplyToModifier = Modifier
|
||||
.padding(top = topPadding, start = 8.dp, end = 8.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.clip(shape)
|
||||
|
||||
val talkbackCompatModifier = if (isTalkbackActive()) {
|
||||
// Use z-index to make the replied to text being read after the message
|
||||
@@ -782,14 +811,25 @@ private fun MessageEventBubbleContent(
|
||||
} else {
|
||||
inReplyToModifier.clickable(onClick = inReplyToClick)
|
||||
}
|
||||
|
||||
val contentHasError = currentContentValidationState.hasError()
|
||||
val borderColor = if (contentHasError) ElementTheme.colors.borderCriticalSubtle else ElementTheme.colors.separatorPrimary
|
||||
val backgroundColor = if (contentHasError) ElementTheme.colors.bgCriticalSubtle else ElementTheme.colors.bgCanvasDefault
|
||||
Box(
|
||||
modifier = talkbackCompatModifier
|
||||
.border(1.dp, ElementTheme.colors.separatorPrimary, RoundedCornerShape(6.dp))
|
||||
.background(ElementTheme.colors.bgCanvasDefault, RoundedCornerShape(6.dp))
|
||||
.border(1.dp, borderColor, shape)
|
||||
.background(backgroundColor, shape)
|
||||
.padding(4.dp)
|
||||
) {
|
||||
val contentValidationState = rememberEventContentValidationState(eventId = inReplyTo.eventId(), eventContent = inReplyTo.content())
|
||||
val updatedEventSink by rememberUpdatedState(eventSink)
|
||||
LaunchedEffect(inReplyTo) {
|
||||
val mediaSources = inReplyTo.content()?.mediaSources() ?: return@LaunchedEffect
|
||||
updatedEventSink(TimelineEvent.ValidateMedia(inReplyTo.eventId(), mediaSources, contentValidationState))
|
||||
}
|
||||
InReplyToView(
|
||||
inReplyTo = inReplyTo,
|
||||
contentValidationValue = currentContentValidationState,
|
||||
hideImage = timelineProtectionState.hideMediaContent(inReplyTo.eventId()),
|
||||
)
|
||||
}
|
||||
@@ -809,30 +849,47 @@ 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()
|
||||
val shouldHide = content.mode is TimelineItemLocationContent.Mode.Live &&
|
||||
content.mode.isActive &&
|
||||
content.mode.isOwnUser
|
||||
if (shouldHide) TimestampPosition.Hidden else TimestampPosition.Overlay
|
||||
val contentValidationState by rememberEventContentValidationState(eventId = event.eventId, needsValidation = event.content.isMedia).collectOverallState()
|
||||
val needsInvalidContentLayout =
|
||||
// Gallery events should not apply custom paddings or layout dispositions
|
||||
event.content !is TimelineItemGalleryContent &&
|
||||
event.content !is TimelineItemAttachmentsContent &&
|
||||
contentValidationState.hasError()
|
||||
|
||||
val timestampPosition = if (needsInvalidContentLayout) {
|
||||
// The invalid content view will be displayed in all these cases, independent of the event content
|
||||
TimestampPosition.Aligned
|
||||
} else {
|
||||
when (val content = event.content) {
|
||||
is TimelineItemImageContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
|
||||
is TimelineItemVideoContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
|
||||
is TimelineItemGalleryContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Below
|
||||
is TimelineItemAttachmentsContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Below
|
||||
is TimelineItemStickerContent -> TimestampPosition.Overlay
|
||||
is TimelineItemLocationContent -> {
|
||||
val content = content.ensureActiveLiveLocation()
|
||||
val shouldHide = content.mode is TimelineItemLocationContent.Mode.Live &&
|
||||
content.mode.isActive &&
|
||||
content.mode.isOwnUser
|
||||
if (shouldHide) TimestampPosition.Hidden else TimestampPosition.Overlay
|
||||
}
|
||||
is TimelineItemPollContent -> TimestampPosition.Below
|
||||
else -> TimestampPosition.Default
|
||||
}
|
||||
is TimelineItemPollContent -> TimestampPosition.Below
|
||||
else -> TimestampPosition.Default
|
||||
}
|
||||
val paddingBehaviour = when (event.content) {
|
||||
is TimelineItemImageContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
|
||||
is TimelineItemVideoContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
|
||||
is TimelineItemGalleryContent -> ContentPadding.CaptionedMedia
|
||||
is TimelineItemAttachmentsContent -> ContentPadding.CaptionedMedia
|
||||
is TimelineItemStickerContent,
|
||||
is TimelineItemLocationContent -> ContentPadding.Media
|
||||
else -> ContentPadding.Textual
|
||||
|
||||
val paddingBehaviour = if (needsInvalidContentLayout) {
|
||||
ContentPadding.InvalidContent
|
||||
} else {
|
||||
when (event.content) {
|
||||
is TimelineItemImageContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
|
||||
is TimelineItemVideoContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
|
||||
is TimelineItemGalleryContent -> ContentPadding.CaptionedMedia
|
||||
is TimelineItemAttachmentsContent -> ContentPadding.CaptionedMedia
|
||||
is TimelineItemStickerContent,
|
||||
is TimelineItemLocationContent -> ContentPadding.Media
|
||||
else -> ContentPadding.Textual
|
||||
}
|
||||
}
|
||||
CommonLayout(
|
||||
showThreadDecoration = timelineMode !is Timeline.Mode.Thread && event.threadInfo is TimelineItemThreadInfo.ThreadResponse,
|
||||
|
||||
+4
-5
@@ -28,7 +28,6 @@ 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.groups.isRedactedMessagesGroup
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
@@ -62,9 +61,9 @@ fun TimelineItemGroupedEventsRow(
|
||||
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit =
|
||||
{ event, contentModifier, onContentLayoutChange ->
|
||||
TimelineItemEventContentView(
|
||||
eventId = event.eventId,
|
||||
content = event.content,
|
||||
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
|
||||
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
eventSink = eventSink,
|
||||
@@ -134,9 +133,9 @@ private fun TimelineItemGroupedEventsRowContent(
|
||||
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit =
|
||||
{ event, contentModifier, onContentLayoutChange ->
|
||||
TimelineItemEventContentView(
|
||||
eventId = event.eventId,
|
||||
content = event.content,
|
||||
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
|
||||
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
eventSink = eventSink,
|
||||
|
||||
+3
-3
@@ -36,7 +36,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.libraries.designsystem.colors.gradientSubtleColors
|
||||
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||
@@ -77,9 +76,9 @@ internal fun TimelineItemRow(
|
||||
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit =
|
||||
{ event, contentModifier, onContentLayoutChange ->
|
||||
TimelineItemEventContentView(
|
||||
eventId = event.eventId,
|
||||
content = event.content,
|
||||
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
|
||||
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
onContentClick = { onContentClick(event) },
|
||||
onGalleryItemClick = { index -> onGalleryItemClick(event, index) },
|
||||
onLongClick = { onLongClick(event) },
|
||||
@@ -119,6 +118,7 @@ internal fun TimelineItemRow(
|
||||
onClick = { onContentClick(timelineItem) },
|
||||
onReadReceiptsClick = onReadReceiptClick,
|
||||
onLongClick = { onLongClick(timelineItem) },
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
}
|
||||
|
||||
+6
-2
@@ -31,6 +31,8 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
|
||||
import io.element.android.features.messages.impl.timeline.util.defaultTimelineContentPadding
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
@@ -43,6 +45,7 @@ fun TimelineItemStateEventRow(
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
onReadReceiptsClick: (event: TimelineItem.Event) -> Unit,
|
||||
timelineProtectionState: TimelineProtectionState,
|
||||
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@@ -67,11 +70,11 @@ fun TimelineItemStateEventRow(
|
||||
.widthIn(max = 320.dp)
|
||||
) {
|
||||
TimelineItemEventContentView(
|
||||
eventId = event.eventId,
|
||||
content = event.content,
|
||||
onLinkClick = {},
|
||||
onLinkLongClick = {},
|
||||
hideMediaContent = false,
|
||||
onShowContentClick = {},
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
eventSink = eventSink,
|
||||
onContentClick = null,
|
||||
onGalleryItemClick = {},
|
||||
@@ -107,6 +110,7 @@ internal fun TimelineItemStateEventRowPreview() = ElementPreview {
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
onReadReceiptsClick = {},
|
||||
timelineProtectionState = aTimelineProtectionState(),
|
||||
eventSink = {}
|
||||
)
|
||||
}
|
||||
|
||||
+22
-42
@@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -25,8 +24,10 @@ import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
|
||||
/**
|
||||
* package-private, you should only use TimelineItemFileView and TimelineItemAudioView.
|
||||
@@ -38,9 +39,9 @@ fun TimelineItemAttachmentView(
|
||||
iconContentDescription: String?,
|
||||
filename: String,
|
||||
fileExtensionAndSize: String,
|
||||
caption: String?,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentValidationValue: ContentValidationValue = ContentValidationValue.Valid,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
@@ -50,16 +51,9 @@ fun TimelineItemAttachmentView(
|
||||
iconContentDescription = iconContentDescription,
|
||||
filename = filename,
|
||||
fileExtensionAndSize = fileExtensionAndSize,
|
||||
hasCaption = caption != null,
|
||||
isLoading = !contentValidationValue.isValidated(),
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
)
|
||||
if (caption != null) {
|
||||
TimelineItemAttachmentCaptionView(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
caption = caption,
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +63,7 @@ private fun TimelineItemAttachmentHeaderView(
|
||||
iconContentDescription: String?,
|
||||
filename: String,
|
||||
fileExtensionAndSize: String,
|
||||
hasCaption: Boolean,
|
||||
isLoading: Boolean,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -86,12 +80,19 @@ private fun TimelineItemAttachmentHeaderView(
|
||||
.background(ElementTheme.colors.bgCanvasDefault, RoundedCornerShape(4.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = iconContentDescription,
|
||||
tint = ElementTheme.colors.iconPrimary,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = ElementTheme.colors.iconPrimary,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = iconContentDescription,
|
||||
tint = ElementTheme.colors.iconPrimary,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Column {
|
||||
Text(
|
||||
@@ -107,32 +108,11 @@ private fun TimelineItemAttachmentHeaderView(
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
onTextLayout = if (hasCaption) {
|
||||
{}
|
||||
} else {
|
||||
ContentAvoidingLayout.measureLastTextLine(
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
extraWidth = iconSize + spacing
|
||||
)
|
||||
},
|
||||
onTextLayout = ContentAvoidingLayout.measureLastTextLine(
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
extraWidth = iconSize + spacing
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimelineItemAttachmentCaptionView(
|
||||
caption: String,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Text(
|
||||
modifier = modifier,
|
||||
text = caption,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
onTextLayout = ContentAvoidingLayout.measureLastTextLine(
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
-1
@@ -38,7 +38,6 @@ class TimelineItemAttachmentsContentProvider : PreviewParameterProvider<Timeline
|
||||
),
|
||||
aTimelineItemAttachmentsContent(
|
||||
body = "Files",
|
||||
caption = "Files mixed with media",
|
||||
attachments = listOf(
|
||||
anAttachmentItem(
|
||||
filename = "report.pdf",
|
||||
|
||||
+111
-58
@@ -7,31 +7,30 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.event
|
||||
|
||||
import android.text.SpannedString
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.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.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
@@ -39,8 +38,6 @@ 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
|
||||
@@ -50,66 +47,124 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NotFoundContentView
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectMediaState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
import kotlin.math.max
|
||||
|
||||
@Composable
|
||||
fun TimelineItemAttachmentsListView(
|
||||
eventId: EventId?,
|
||||
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,
|
||||
val contentValidationState = rememberEventContentValidationState(
|
||||
eventId = eventId,
|
||||
needsValidation = content.isMedia,
|
||||
)
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
val validationStates = remember(content.attachments) {
|
||||
buildList {
|
||||
for (attachment in content.attachments) {
|
||||
add(validationStateForAttachment(attachment, contentValidationState))
|
||||
}
|
||||
}.toMutableStateList()
|
||||
}
|
||||
content.attachments.forEachIndexed { index, attachment ->
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
val thumbnailContentValidationState by contentValidationState.collectMediaState(attachment.thumbnailSource?.safeUrl)
|
||||
val mediaContentValidationState by contentValidationState.collectMediaState(attachment.mediaSource.safeUrl)
|
||||
|
||||
val itemContentValidationState = remember(thumbnailContentValidationState, mediaContentValidationState) {
|
||||
validationStateForAttachment(attachment, contentValidationState)
|
||||
}
|
||||
|
||||
LaunchedEffect(itemContentValidationState) {
|
||||
validationStates[index] = itemContentValidationState
|
||||
}
|
||||
|
||||
val needsSeparator = index in 1..max(1, content.attachments.lastIndex)
|
||||
val isPreviousItemInvalid = validationStates.getOrNull(index - 1)?.hasError() == true
|
||||
if (needsSeparator && !itemContentValidationState.hasError() && !isPreviousItemInvalid) {
|
||||
HorizontalDivider(
|
||||
color = ElementTheme.colors.borderInteractiveSecondary,
|
||||
)
|
||||
}
|
||||
|
||||
when (itemContentValidationState) {
|
||||
ContentValidationValue.Invalid -> {
|
||||
val shape = RoundedCornerShape(6.dp)
|
||||
InvalidContentView(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 6.dp)
|
||||
.border(width = 1.dp, color = ElementTheme.colors.borderCriticalSubtle, shape = shape)
|
||||
.clip(shape),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
AttachmentListItem(
|
||||
attachment = attachment,
|
||||
onClick = { onGalleryItemClick(index) },
|
||||
is ContentValidationValue.UnrecoverableError -> {
|
||||
val shape = RoundedCornerShape(6.dp)
|
||||
NotFoundContentView(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 6.dp)
|
||||
.border(width = 1.dp, color = ElementTheme.colors.borderCriticalSubtle, shape = shape)
|
||||
.clip(shape),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
ContentValidationValue.Loading -> {
|
||||
CircularProgressIndicator(modifier = Modifier.padding(8.dp).align(Alignment.CenterHorizontally))
|
||||
}
|
||||
ContentValidationValue.Valid -> {
|
||||
AttachmentListItem(
|
||||
attachment = attachment,
|
||||
onClick = { onGalleryItemClick(index) },
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
// TODO handle cases where either the content hasn't started validation or it failed to do so
|
||||
}
|
||||
}
|
||||
|
||||
if (index == content.attachments.lastIndex && content.showCaption && !itemContentValidationState.isInvalid()) {
|
||||
HorizontalDivider(
|
||||
color = ElementTheme.colors.borderInteractiveSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun validationStateForAttachment(
|
||||
attachment: AttachmentItem,
|
||||
contentValidationState: ContentValidationState,
|
||||
): ContentValidationValue {
|
||||
val thumbnailValue = attachment.thumbnailSource?.safeUrl?.let { contentValidationState.getCurrentMediaState(it) } ?: ContentValidationValue.Unknown
|
||||
val mediaValue = contentValidationState.getCurrentMediaState(attachment.mediaSource.safeUrl)
|
||||
return validationStateForAttachment(thumbnailValue, mediaValue)
|
||||
}
|
||||
|
||||
private fun validationStateForAttachment(
|
||||
thumbnailValue: ContentValidationValue,
|
||||
mediaValue: ContentValidationValue,
|
||||
): ContentValidationValue {
|
||||
return if (thumbnailValue.isInvalid() || mediaValue.isInvalid()) {
|
||||
ContentValidationValue.Invalid
|
||||
} else if (thumbnailValue.hasUnrecoverableError() || mediaValue.hasUnrecoverableError()) {
|
||||
listOf(thumbnailValue, mediaValue).first { it.hasUnrecoverableError() }
|
||||
} else if (thumbnailValue.isLoading() || mediaValue.isLoading()) {
|
||||
ContentValidationValue.Loading
|
||||
} else {
|
||||
mediaValue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,10 +268,8 @@ internal fun TimelineItemAttachmentsListViewPreview(
|
||||
@PreviewParameter(TimelineItemAttachmentsContentProvider::class) content: TimelineItemAttachmentsContent
|
||||
) = ElementPreview {
|
||||
TimelineItemAttachmentsListView(
|
||||
eventId = EventId("\$eventId"),
|
||||
content = content,
|
||||
onGalleryItemClick = {},
|
||||
onLinkClick = {},
|
||||
onLinkLongClick = {},
|
||||
onContentLayoutChange = {},
|
||||
)
|
||||
}
|
||||
|
||||
+3
-1
@@ -16,21 +16,23 @@ import io.element.android.features.messages.impl.timeline.components.layout.Cont
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContentProvider
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
|
||||
@Composable
|
||||
fun TimelineItemAudioView(
|
||||
content: TimelineItemAudioContent,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentValidationValue: ContentValidationValue = ContentValidationValue.Valid,
|
||||
) {
|
||||
TimelineItemAttachmentView(
|
||||
icon = CompoundIcons.Audio(),
|
||||
iconContentDescription = null,
|
||||
filename = content.filename,
|
||||
fileExtensionAndSize = content.fileExtensionAndSize,
|
||||
caption = content.caption,
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
modifier = modifier,
|
||||
contentValidationValue = contentValidationValue,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+253
-106
@@ -8,9 +8,23 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.event
|
||||
|
||||
import android.text.SpannedString
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvent
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.di.rememberPresenter
|
||||
@@ -32,132 +46,265 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.captionOrNull
|
||||
import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation
|
||||
import io.element.android.features.messages.impl.timeline.model.event.formattedCaptionOrNull
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.designsystem.components.EqualWidthColumn
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NotFoundContentView
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
|
||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
|
||||
@Composable
|
||||
fun TimelineItemEventContentView(
|
||||
eventId: EventId?,
|
||||
content: TimelineItemEventContent,
|
||||
hideMediaContent: Boolean,
|
||||
onContentClick: (() -> Unit)?,
|
||||
onGalleryItemClick: ((Int) -> Unit),
|
||||
timelineProtectionState: TimelineProtectionState,
|
||||
onLongClick: (() -> Unit)?,
|
||||
onShowContentClick: () -> Unit,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {},
|
||||
) {
|
||||
val presenterFactories = LocalTimelineItemPresenterFactories.current
|
||||
when (content) {
|
||||
is TimelineItemEncryptedContent -> TimelineItemEncryptedView(
|
||||
content = content,
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
modifier = modifier
|
||||
)
|
||||
is TimelineItemRedactedContent -> TimelineItemRedactedView(
|
||||
content = content,
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
modifier = modifier
|
||||
)
|
||||
is TimelineItemTextBasedContent -> TimelineItemTextView(
|
||||
content = content,
|
||||
modifier = modifier,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
onContentLayoutChange = onContentLayoutChange
|
||||
)
|
||||
is TimelineItemUnknownContent -> TimelineItemUnknownView(
|
||||
content = content,
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
modifier = modifier
|
||||
)
|
||||
is TimelineItemLocationContent -> {
|
||||
TimelineItemLocationView(
|
||||
content = content.ensureActiveLiveLocation(),
|
||||
onStopLiveLocationClick = { eventSink(TimelineEvent.StopLiveLocationShare) },
|
||||
modifier = modifier
|
||||
)
|
||||
val hideMediaContent = remember(eventId, timelineProtectionState.protectionState) {
|
||||
timelineProtectionState.hideMediaContent(eventId)
|
||||
}
|
||||
|
||||
val onShowContentClick = remember(timelineProtectionState.eventSink) {
|
||||
{
|
||||
timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(eventId))
|
||||
}
|
||||
is TimelineItemImageContent -> TimelineItemImageView(
|
||||
content = content,
|
||||
hideMediaContent = hideMediaContent,
|
||||
onContentClick = onContentClick,
|
||||
onLongClick = onLongClick,
|
||||
onShowContentClick = onShowContentClick,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
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,
|
||||
onContentClick = onContentClick,
|
||||
onLongClick = onLongClick,
|
||||
onShowClick = onShowContentClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
is TimelineItemVideoContent -> TimelineItemVideoView(
|
||||
content = content,
|
||||
hideMediaContent = hideMediaContent,
|
||||
onContentClick = onContentClick,
|
||||
onLongClick = onLongClick,
|
||||
onShowContentClick = onShowContentClick,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
modifier = modifier
|
||||
)
|
||||
is TimelineItemFileContent -> TimelineItemFileView(
|
||||
content = content,
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
modifier = modifier
|
||||
)
|
||||
is TimelineItemAudioContent -> TimelineItemAudioView(
|
||||
content = content,
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
modifier = modifier
|
||||
)
|
||||
is TimelineItemLegacyCallInviteContent -> TimelineItemLegacyCallInviteView(modifier = modifier)
|
||||
is TimelineItemStateContent -> TimelineItemStateView(
|
||||
content = content,
|
||||
modifier = modifier
|
||||
)
|
||||
is TimelineItemPollContent -> TimelineItemPollView(
|
||||
content = content,
|
||||
eventSink = eventSink,
|
||||
modifier = modifier,
|
||||
)
|
||||
is TimelineItemVoiceContent -> {
|
||||
val presenter: Presenter<VoiceMessageState> = presenterFactories.rememberPresenter(content)
|
||||
TimelineItemVoiceView(
|
||||
state = presenter.present(),
|
||||
content = content,
|
||||
}
|
||||
|
||||
val contentValidationState = rememberEventContentValidationState(eventId = eventId, needsValidation = content.isMedia)
|
||||
val overallValidationState by contentValidationState.collectOverallState()
|
||||
val needsContentValidationPerItem = remember(content) { content is TimelineItemGalleryContent || content is TimelineItemAttachmentsContent }
|
||||
|
||||
if (eventId != null) {
|
||||
ValidateMediaHelper(eventId, content, contentValidationState, eventSink)
|
||||
}
|
||||
|
||||
val caption = content.captionOrNull()
|
||||
val showCaption = caption != null && content !is TimelineItemStickerContent && content !is TimelineItemVoiceContent
|
||||
// If a caption is added, it will be used to get the free space for the overlay, so we don't need to add onContentLayoutChange to the actual content
|
||||
val calculatedOnContentLayoutChange = remember(onContentLayoutChange, showCaption) {
|
||||
if (showCaption) {
|
||||
{}
|
||||
} else {
|
||||
onContentLayoutChange
|
||||
}
|
||||
}
|
||||
|
||||
val displayInvalidContent = overallValidationState.isInvalid() && !needsContentValidationPerItem
|
||||
val displayContentNotFound = overallValidationState.hasUnrecoverableError() && !needsContentValidationPerItem
|
||||
EqualWidthColumn(modifier = modifier) {
|
||||
if (displayInvalidContent) {
|
||||
InvalidContentView(
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
||||
onTextLayout = ContentAvoidingLayout.measureLastTextLine(
|
||||
onContentLayoutChange = calculatedOnContentLayoutChange,
|
||||
// Icon + horizontal paddings
|
||||
extraWidth = 24.dp + 20.dp,
|
||||
)
|
||||
)
|
||||
} else if (displayContentNotFound) {
|
||||
NotFoundContentView(
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
||||
onTextLayout = ContentAvoidingLayout.measureLastTextLine(
|
||||
onContentLayoutChange = calculatedOnContentLayoutChange,
|
||||
// Icon + horizontal paddings
|
||||
extraWidth = 24.dp + 20.dp,
|
||||
)
|
||||
)
|
||||
} else {
|
||||
val presenterFactories = LocalTimelineItemPresenterFactories.current
|
||||
when (content) {
|
||||
is TimelineItemEncryptedContent -> TimelineItemEncryptedView(
|
||||
content = content,
|
||||
onContentLayoutChange = calculatedOnContentLayoutChange,
|
||||
)
|
||||
is TimelineItemRedactedContent -> TimelineItemRedactedView(
|
||||
content = content,
|
||||
onContentLayoutChange = calculatedOnContentLayoutChange,
|
||||
)
|
||||
is TimelineItemTextBasedContent -> TimelineItemTextView(
|
||||
content = content,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
onContentLayoutChange = calculatedOnContentLayoutChange,
|
||||
)
|
||||
is TimelineItemUnknownContent -> TimelineItemUnknownView(
|
||||
content = content,
|
||||
onContentLayoutChange = calculatedOnContentLayoutChange,
|
||||
)
|
||||
is TimelineItemLocationContent -> {
|
||||
TimelineItemLocationView(
|
||||
content = content.ensureActiveLiveLocation(),
|
||||
onStopLiveLocationClick = { eventSink(TimelineEvent.StopLiveLocationShare) },
|
||||
)
|
||||
}
|
||||
is TimelineItemImageContent -> {
|
||||
TimelineItemImageView(
|
||||
content = content,
|
||||
hideMediaContent = hideMediaContent,
|
||||
onContentClick = onContentClick,
|
||||
onLongClick = onLongClick,
|
||||
onShowContentClick = onShowContentClick,
|
||||
contentValidationState = contentValidationState,
|
||||
)
|
||||
}
|
||||
is TimelineItemStickerContent -> {
|
||||
TimelineItemStickerView(
|
||||
content = content,
|
||||
hideMediaContent = hideMediaContent,
|
||||
onContentClick = onContentClick,
|
||||
onLongClick = onLongClick,
|
||||
onShowClick = onShowContentClick,
|
||||
contentValidationState = contentValidationState,
|
||||
)
|
||||
}
|
||||
is TimelineItemVideoContent -> {
|
||||
TimelineItemVideoView(
|
||||
content = content,
|
||||
hideMediaContent = hideMediaContent,
|
||||
onContentClick = onContentClick,
|
||||
onLongClick = onLongClick,
|
||||
onShowContentClick = onShowContentClick,
|
||||
contentValidationState = contentValidationState,
|
||||
)
|
||||
}
|
||||
is TimelineItemGalleryContent -> TimelineItemGalleryView(
|
||||
eventId = eventId,
|
||||
content = content,
|
||||
onGalleryItemClick = { index -> onGalleryItemClick(index) },
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
is TimelineItemAttachmentsContent -> TimelineItemAttachmentsListView(
|
||||
eventId = eventId,
|
||||
content = content,
|
||||
onGalleryItemClick = { index -> onGalleryItemClick(index) },
|
||||
)
|
||||
is TimelineItemFileContent -> {
|
||||
TimelineItemFileView(
|
||||
content = content,
|
||||
onContentLayoutChange = calculatedOnContentLayoutChange,
|
||||
contentValidationValue = overallValidationState,
|
||||
)
|
||||
}
|
||||
is TimelineItemAudioContent -> {
|
||||
TimelineItemAudioView(
|
||||
content = content,
|
||||
onContentLayoutChange = calculatedOnContentLayoutChange,
|
||||
contentValidationValue = overallValidationState,
|
||||
)
|
||||
}
|
||||
is TimelineItemLegacyCallInviteContent -> TimelineItemLegacyCallInviteView()
|
||||
is TimelineItemStateContent -> TimelineItemStateView(
|
||||
content = content,
|
||||
)
|
||||
is TimelineItemPollContent -> TimelineItemPollView(
|
||||
content = content,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
is TimelineItemVoiceContent -> {
|
||||
val presenter: Presenter<VoiceMessageState> = presenterFactories.rememberPresenter(content)
|
||||
TimelineItemVoiceView(
|
||||
state = presenter.present(),
|
||||
content = content,
|
||||
onContentLayoutChange = calculatedOnContentLayoutChange,
|
||||
contentValidationValue = overallValidationState,
|
||||
)
|
||||
}
|
||||
is TimelineItemRtcNotificationContent -> error("This shouldn't be rendered as the content of a bubble")
|
||||
}
|
||||
}
|
||||
|
||||
if (showCaption) {
|
||||
val padding = if (displayInvalidContent) {
|
||||
PaddingValues(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 0.dp)
|
||||
} else {
|
||||
when (content) {
|
||||
is TimelineItemImageContent,
|
||||
is TimelineItemVideoContent,
|
||||
is TimelineItemGalleryContent,
|
||||
is TimelineItemAttachmentsContent -> PaddingValues(start = 4.dp, end = 4.dp, top = 8.dp, bottom = 0.dp)
|
||||
else -> PaddingValues(horizontal = 12.dp, vertical = 8.dp)
|
||||
}
|
||||
}
|
||||
CaptionView(
|
||||
modifier = Modifier.padding(padding),
|
||||
caption = caption,
|
||||
formattedCaption = content.formattedCaptionOrNull(),
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
is TimelineItemRtcNotificationContent -> error("This shouldn't be rendered as the content of a bubble")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CaptionView(
|
||||
caption: String,
|
||||
formattedCaption: CharSequence?,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val caption = if (LocalInspectionMode.current) {
|
||||
SpannedString(caption)
|
||||
} else {
|
||||
formattedCaption ?: SpannedString(caption)
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides ElementTheme.colors.textPrimary,
|
||||
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
|
||||
) {
|
||||
EditorStyledText(
|
||||
modifier = modifier,
|
||||
text = caption,
|
||||
style = ElementRichTextEditorStyle.textStyle(),
|
||||
onLinkClickedListener = onLinkClick,
|
||||
onLinkLongClickedListener = onLinkLongClick,
|
||||
releaseOnDetach = false,
|
||||
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ValidateMediaHelper(
|
||||
eventId: EventId,
|
||||
content: TimelineItemEventContent,
|
||||
contentValidationState: ContentValidationState,
|
||||
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
|
||||
) {
|
||||
val mediaSources = when (content) {
|
||||
is TimelineItemImageContent -> listOfNotNull(content.thumbnailSource, content.mediaSource)
|
||||
is TimelineItemStickerContent -> listOfNotNull(content.thumbnailSource, content.mediaSource)
|
||||
is TimelineItemVideoContent -> listOfNotNull(content.thumbnailSource, content.mediaSource)
|
||||
is TimelineItemFileContent -> listOfNotNull(content.thumbnailSource, content.mediaSource)
|
||||
is TimelineItemAudioContent -> listOf(content.mediaSource)
|
||||
is TimelineItemVoiceContent -> listOf(content.mediaSource)
|
||||
is TimelineItemGalleryContent -> content.items.flatMap { listOfNotNull(it.thumbnailSource, it.mediaSource) }
|
||||
is TimelineItemAttachmentsContent -> content.attachments.flatMap { listOfNotNull(it.thumbnailSource, it.mediaSource) }
|
||||
else -> return
|
||||
}
|
||||
val updatedEventSink by rememberUpdatedState(eventSink)
|
||||
LaunchedEffect(eventId, mediaSources) {
|
||||
updatedEventSink(TimelineEvent.ValidateMedia(eventId, mediaSources, contentValidationState))
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -17,6 +17,7 @@ import io.element.android.features.messages.impl.timeline.components.layout.Cont
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContentProvider
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
/**
|
||||
@@ -27,15 +28,16 @@ fun TimelineItemFileView(
|
||||
content: TimelineItemFileContent,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentValidationValue: ContentValidationValue = ContentValidationValue.Valid,
|
||||
) {
|
||||
TimelineItemAttachmentView(
|
||||
icon = CompoundIcons.Attachment(),
|
||||
iconContentDescription = stringResource(CommonStrings.common_file),
|
||||
filename = content.filename,
|
||||
fileExtensionAndSize = content.fileExtensionAndSize,
|
||||
caption = content.caption,
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
modifier = modifier,
|
||||
contentValidationValue = contentValidationValue,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -116,6 +116,7 @@ fun aGalleryItem(
|
||||
width: Int = 400,
|
||||
height: Int = 300,
|
||||
duration: Duration = Duration.ZERO,
|
||||
mediaSource: MediaSource = MediaSource(url = "", json = ""),
|
||||
): GalleryItem {
|
||||
return GalleryItem(
|
||||
filename = filename,
|
||||
@@ -125,7 +126,7 @@ fun aGalleryItem(
|
||||
GalleryItem.Type.File -> "application/pdf"
|
||||
GalleryItem.Type.Image -> "image/jpeg"
|
||||
},
|
||||
mediaSource = MediaSource(url = "", json = ""),
|
||||
mediaSource = mediaSource,
|
||||
thumbnailSource = null,
|
||||
width = width,
|
||||
height = height,
|
||||
|
||||
+54
-42
@@ -7,7 +7,6 @@
|
||||
|
||||
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
|
||||
@@ -21,28 +20,24 @@ 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.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.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
|
||||
@@ -50,10 +45,11 @@ 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.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectMediaState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
|
||||
import io.element.android.libraries.ui.utils.time.formatShort
|
||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
private const val MAX_TILES = 5
|
||||
@@ -67,12 +63,10 @@ private val THREE_IMAGE_ROW_HEIGHT = 85.dp
|
||||
|
||||
@Composable
|
||||
fun TimelineItemGalleryView(
|
||||
eventId: EventId?,
|
||||
content: TimelineItemGalleryContent,
|
||||
onGalleryItemClick: (Int) -> Unit,
|
||||
onLongClick: (() -> Unit)?,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val totalItems = content.items.size
|
||||
@@ -87,21 +81,25 @@ fun TimelineItemGalleryView(
|
||||
when (totalItems) {
|
||||
0 -> Unit
|
||||
1 -> SingleItemLayout(
|
||||
eventId = eventId,
|
||||
item = content.items[0],
|
||||
onClick = { onGalleryItemClick(0) },
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
2 -> TwoItemLayout(
|
||||
eventId = eventId,
|
||||
items = content.items,
|
||||
onItemClick = onGalleryItemClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
3 -> ThreeItemLayout(
|
||||
eventId = eventId,
|
||||
items = content.items,
|
||||
onItemClick = onGalleryItemClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
else -> FourPlusItemLayout(
|
||||
eventId = eventId,
|
||||
items = content.items,
|
||||
showOverflow = showOverflow,
|
||||
overflowCount = overflowCount,
|
||||
@@ -110,40 +108,18 @@ fun TimelineItemGalleryView(
|
||||
)
|
||||
}
|
||||
}
|
||||
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(
|
||||
eventId: EventId?,
|
||||
item: GalleryItem,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: (() -> Unit)?,
|
||||
) {
|
||||
GalleryItemCell(
|
||||
eventId = eventId,
|
||||
item = item,
|
||||
isLast = false,
|
||||
remainingCount = 0,
|
||||
@@ -157,6 +133,7 @@ private fun SingleItemLayout(
|
||||
|
||||
@Composable
|
||||
private fun TwoItemLayout(
|
||||
eventId: EventId?,
|
||||
items: ImmutableList<GalleryItem>,
|
||||
onItemClick: (Int) -> Unit,
|
||||
onLongClick: (() -> Unit)?,
|
||||
@@ -167,6 +144,7 @@ private fun TwoItemLayout(
|
||||
) {
|
||||
items.forEachIndexed { index, item ->
|
||||
GalleryItemCell(
|
||||
eventId = eventId,
|
||||
item = item,
|
||||
isLast = false,
|
||||
remainingCount = 0,
|
||||
@@ -182,6 +160,7 @@ private fun TwoItemLayout(
|
||||
|
||||
@Composable
|
||||
private fun ThreeItemLayout(
|
||||
eventId: EventId?,
|
||||
items: ImmutableList<GalleryItem>,
|
||||
onItemClick: (Int) -> Unit,
|
||||
onLongClick: (() -> Unit)?,
|
||||
@@ -191,6 +170,7 @@ private fun ThreeItemLayout(
|
||||
verticalArrangement = Arrangement.spacedBy(GRID_SPACING),
|
||||
) {
|
||||
GalleryItemCell(
|
||||
eventId = eventId,
|
||||
item = items[0],
|
||||
isLast = false,
|
||||
remainingCount = 0,
|
||||
@@ -206,6 +186,7 @@ private fun ThreeItemLayout(
|
||||
) {
|
||||
for (it in 1..2) {
|
||||
GalleryItemCell(
|
||||
eventId = eventId,
|
||||
item = items[it],
|
||||
isLast = false,
|
||||
remainingCount = 0,
|
||||
@@ -222,6 +203,7 @@ private fun ThreeItemLayout(
|
||||
|
||||
@Composable
|
||||
private fun FourPlusItemLayout(
|
||||
eventId: EventId?,
|
||||
items: ImmutableList<GalleryItem>,
|
||||
showOverflow: Boolean,
|
||||
overflowCount: Int,
|
||||
@@ -238,6 +220,7 @@ private fun FourPlusItemLayout(
|
||||
) {
|
||||
for (it in 0..1) {
|
||||
GalleryItemCell(
|
||||
eventId = eventId,
|
||||
item = items[it],
|
||||
isLast = false,
|
||||
remainingCount = 0,
|
||||
@@ -260,6 +243,7 @@ private fun FourPlusItemLayout(
|
||||
if (itemIndex < items.size) {
|
||||
val isOverflowItem = showOverflow && i == bottomRowItems - 1
|
||||
GalleryItemCell(
|
||||
eventId = eventId,
|
||||
item = items[itemIndex],
|
||||
isLast = isOverflowItem,
|
||||
remainingCount = if (isOverflowItem) overflowCount else 0,
|
||||
@@ -277,6 +261,7 @@ private fun FourPlusItemLayout(
|
||||
|
||||
@Composable
|
||||
private fun GalleryItemCell(
|
||||
eventId: EventId?,
|
||||
item: GalleryItem,
|
||||
isLast: Boolean,
|
||||
remainingCount: Int,
|
||||
@@ -284,6 +269,22 @@ private fun GalleryItemCell(
|
||||
onLongClick: (() -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val eventContentValidationState = rememberEventContentValidationState(eventId, needsValidation = true)
|
||||
val thumbnailContentValidationState by eventContentValidationState.collectMediaState(item.thumbnailSource?.safeUrl)
|
||||
val mediaContentValidationState by eventContentValidationState.collectMediaState(item.mediaSource.safeUrl)
|
||||
|
||||
val itemContentValidationState = remember(thumbnailContentValidationState, mediaContentValidationState) {
|
||||
if (thumbnailContentValidationState.isInvalid() || mediaContentValidationState.isInvalid()) {
|
||||
ContentValidationValue.Invalid
|
||||
} else if (thumbnailContentValidationState.hasUnrecoverableError() || mediaContentValidationState.hasUnrecoverableError()) {
|
||||
listOf(thumbnailContentValidationState, mediaContentValidationState).first { it is ContentValidationValue.UnrecoverableError }
|
||||
} else if (thumbnailContentValidationState.isLoading() || mediaContentValidationState.isLoading()) {
|
||||
ContentValidationValue.Loading
|
||||
} else {
|
||||
mediaContentValidationState
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.blurHashBackground(item.blurhash, alpha = 0.9f)
|
||||
@@ -305,7 +306,20 @@ private fun GalleryItemCell(
|
||||
VideoOverlay(duration = item.duration)
|
||||
}
|
||||
|
||||
if (isLast && remainingCount > 0) {
|
||||
if (itemContentValidationState.isLoading()) {
|
||||
CircularProgressIndicator()
|
||||
} else if (itemContentValidationState.hasError()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(ElementTheme.colors.bgCriticalSubtle)
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
imageVector = CompoundIcons.Error(),
|
||||
tint = ElementTheme.colors.iconCriticalPrimary,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
} else if (isLast && remainingCount > 0) {
|
||||
RemainingCountOverlay(count = remainingCount)
|
||||
}
|
||||
}
|
||||
@@ -374,11 +388,9 @@ internal fun TimelineItemGalleryViewPreview(
|
||||
@PreviewParameter(TimelineItemGalleryContentProvider::class) content: TimelineItemGalleryContent,
|
||||
) = ElementPreview {
|
||||
TimelineItemGalleryView(
|
||||
eventId = null,
|
||||
content = content,
|
||||
onGalleryItemClick = {},
|
||||
onLongClick = {},
|
||||
onLinkClick = {},
|
||||
onLinkLongClick = {},
|
||||
onContentLayoutChange = {},
|
||||
)
|
||||
}
|
||||
|
||||
+56
-121
@@ -8,20 +8,13 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.event
|
||||
|
||||
import android.text.SpannedString
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -31,118 +24,97 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.features.messages.impl.timeline.components.ATimelineItemEventRow
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.protection.ProtectedView
|
||||
import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent
|
||||
import io.element.android.features.messages.impl.timeline.util.handleAsyncImageStateChange
|
||||
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
|
||||
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
|
||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
|
||||
private const val TALL_IMAGE_RATIO_DIVISOR = 3
|
||||
@Composable
|
||||
fun TimelineItemImageView(
|
||||
content: TimelineItemImageContent,
|
||||
hideMediaContent: Boolean,
|
||||
onContentClick: (() -> Unit)?,
|
||||
onLongClick: (() -> Unit)?,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
onShowContentClick: () -> Unit,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentValidationState: ContentValidationState = remember { DefaultContentValidationState() },
|
||||
) {
|
||||
val a11yLabel = stringResource(CommonStrings.common_image)
|
||||
val description = content.caption?.let { "$a11yLabel: $it" } ?: a11yLabel
|
||||
Column(modifier = modifier) {
|
||||
Column(modifier = modifier.wrapContentWidth(Alignment.CenterHorizontally)) {
|
||||
val containerModifier = if (content.showCaption) {
|
||||
Modifier.clip(RoundedCornerShape(10.dp))
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
val eventContentValidation by contentValidationState.collectOverallState()
|
||||
val isContentBeingValidated = !eventContentValidation.isValidated()
|
||||
TimelineItemAspectRatioBox(
|
||||
modifier = containerModifier.blurHashBackground(content.blurhash, alpha = 0.9f).align(Alignment.CenterHorizontally),
|
||||
modifier = containerModifier
|
||||
.blurHashBackground(content.blurhash, alpha = 0.9f)
|
||||
.align(Alignment.CenterHorizontally),
|
||||
aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent),
|
||||
) {
|
||||
ProtectedView(
|
||||
hideContent = hideMediaContent,
|
||||
onShowClick = onShowContentClick,
|
||||
) {
|
||||
var isLoaded by remember { mutableStateOf(false) }
|
||||
AsyncImage(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (isLoaded) Modifier.background(Color.White) else Modifier)
|
||||
.then(
|
||||
if (!isTalkbackActive() && onContentClick != null) {
|
||||
Modifier
|
||||
.combinedClickable(
|
||||
onClick = onContentClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
.onKeyboardContextMenuAction(onLongClick)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
model = content.thumbnailMediaRequestData,
|
||||
contentScale = ContentScale.Crop,
|
||||
alignment = Alignment.Center,
|
||||
contentDescription = description,
|
||||
onState = { isLoaded = it is AsyncImagePainter.State.Success },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (content.showCaption) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val caption = if (LocalInspectionMode.current) {
|
||||
SpannedString(content.caption)
|
||||
if (isContentBeingValidated) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
} else {
|
||||
content.formattedCaption ?: SpannedString(content.caption)
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides ElementTheme.colors.textPrimary,
|
||||
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
|
||||
) {
|
||||
val width = content.width ?: 0
|
||||
val height = content.height ?: 0
|
||||
// if image is narrow and tall use DEFAULT_ASPECT_RATIO
|
||||
val aspectRatio = if (width < height / TALL_IMAGE_RATIO_DIVISOR) {
|
||||
DEFAULT_ASPECT_RATIO
|
||||
} else {
|
||||
content.aspectRatio ?: DEFAULT_ASPECT_RATIO
|
||||
ProtectedView(
|
||||
hideContent = hideMediaContent,
|
||||
onShowClick = onShowContentClick,
|
||||
) {
|
||||
var isLoaded by remember { mutableStateOf(false) }
|
||||
AsyncImage(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (isLoaded) Modifier.background(Color.White) else Modifier)
|
||||
.then(
|
||||
if (!isTalkbackActive() && onContentClick != null) {
|
||||
Modifier
|
||||
.combinedClickable(
|
||||
onClick = onContentClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
.onKeyboardContextMenuAction(onLongClick)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
model = content.thumbnailMediaRequestData,
|
||||
contentScale = ContentScale.Crop,
|
||||
alignment = Alignment.Center,
|
||||
contentDescription = description,
|
||||
onState = { state ->
|
||||
val url = content.thumbnailMediaRequestData.source?.safeUrl
|
||||
if (url != null) {
|
||||
handleAsyncImageStateChange(
|
||||
state = state,
|
||||
onLoaded = { isLoaded = true },
|
||||
updateContentValidationState = { contentValidationState.update(url, it) },
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
EditorStyledText(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 4.dp) // This is (12.dp - 8.dp) contentPadding from CommonLayout
|
||||
.widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio),
|
||||
text = caption,
|
||||
style = ElementRichTextEditorStyle.textStyle(),
|
||||
onLinkClickedListener = onLinkClick,
|
||||
onLinkLongClickedListener = onLinkLongClick,
|
||||
releaseOnDetach = false,
|
||||
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,9 +129,6 @@ internal fun TimelineItemImageViewPreview(@PreviewParameter(TimelineItemImageCon
|
||||
onShowContentClick = {},
|
||||
onContentClick = {},
|
||||
onLongClick = {},
|
||||
onLinkClick = {},
|
||||
onLinkLongClick = {},
|
||||
onContentLayoutChange = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -172,43 +141,9 @@ internal fun TimelineItemImageViewHideMediaContentPreview() = ElementPreview {
|
||||
onShowContentClick = {},
|
||||
onContentClick = {},
|
||||
onLongClick = {},
|
||||
onLinkClick = {},
|
||||
onLinkLongClick = {},
|
||||
onContentLayoutChange = {},
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineImageWithCaptionRowPreview() = ElementPreview {
|
||||
Column {
|
||||
sequenceOf(false, true).forEach { isMine ->
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
isMine = isMine,
|
||||
content = aTimelineItemImageContent(
|
||||
filename = "image.jpg",
|
||||
caption = "A long caption that may wrap into several lines",
|
||||
aspectRatio = 2.5f,
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
),
|
||||
)
|
||||
}
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
isMine = false,
|
||||
content = aTimelineItemImageContent(
|
||||
filename = "image.jpg",
|
||||
caption = "Image with null aspectRatio",
|
||||
aspectRatio = null,
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun ATimelineItemEventRowPreview() = ElementPreview {
|
||||
@@ -220,9 +155,9 @@ internal fun ATimelineItemEventRowPreview() = ElementPreview {
|
||||
content = aTimelineItemImageContent(
|
||||
filename = "image.jpg",
|
||||
caption = "A long caption that may wrap into several lines",
|
||||
width = 80,
|
||||
height = 300,
|
||||
aspectRatio = 80f / 300f,
|
||||
width = 40,
|
||||
height = 20,
|
||||
aspectRatio = 40f / 20f,
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
),
|
||||
@@ -235,7 +170,7 @@ internal fun ATimelineItemEventRowPreview() = ElementPreview {
|
||||
filename = "image.jpg",
|
||||
caption = "Narrow image with null aspectRatio",
|
||||
width = 80,
|
||||
height = 300,
|
||||
height = 150,
|
||||
aspectRatio = null,
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
|
||||
+28
-5
@@ -12,6 +12,7 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -26,16 +27,20 @@ import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContentProvider
|
||||
import io.element.android.features.messages.impl.timeline.protection.ProtectedView
|
||||
import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent
|
||||
import io.element.android.features.messages.impl.timeline.util.handleAsyncImageStateChange
|
||||
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
|
||||
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
private const val STICKER_SIZE_IN_DP = 128
|
||||
@@ -48,10 +53,14 @@ fun TimelineItemStickerView(
|
||||
onLongClick: (() -> Unit)?,
|
||||
onShowClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentValidationState: ContentValidationState = remember { DefaultContentValidationState() },
|
||||
) {
|
||||
val description = content.bestDescription.takeIf { it.isNotEmpty() } ?: stringResource(CommonStrings.common_image)
|
||||
|
||||
val eventContentValidation by contentValidationState.collectOverallState()
|
||||
val isContentBeingValidated = !eventContentValidation.isValidated()
|
||||
Column(
|
||||
modifier = modifier.semantics { contentDescription = description },
|
||||
modifier = modifier.semantics { contentDescription = description }.wrapContentWidth(Alignment.CenterHorizontally),
|
||||
) {
|
||||
TimelineItemAspectRatioBox(
|
||||
modifier = Modifier.blurHashBackground(content.blurhash, alpha = 0.9f),
|
||||
@@ -63,11 +72,12 @@ fun TimelineItemStickerView(
|
||||
hideContent = hideMediaContent,
|
||||
onShowClick = onShowClick,
|
||||
) {
|
||||
var isLoaded by remember { mutableStateOf(false) }
|
||||
var isThumbnailLoaded by remember { mutableStateOf(false) }
|
||||
|
||||
AsyncImage(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(if (isLoaded) Modifier.background(Color.White) else Modifier)
|
||||
.then(if (isThumbnailLoaded) Modifier.background(Color.White) else Modifier)
|
||||
.then(
|
||||
if (onContentClick != null) {
|
||||
Modifier
|
||||
@@ -91,9 +101,22 @@ fun TimelineItemStickerView(
|
||||
contentScale = ContentScale.Crop,
|
||||
alignment = Alignment.Center,
|
||||
contentDescription = description,
|
||||
onState = { isLoaded = it is AsyncImagePainter.State.Success },
|
||||
onState = { state ->
|
||||
val url = content.preferredMediaSource?.safeUrl
|
||||
if (url != null) {
|
||||
handleAsyncImageStateChange(
|
||||
state = state,
|
||||
onLoaded = { isThumbnailLoaded = true },
|
||||
updateContentValidationState = { contentValidationState.update(url, it) },
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (isContentBeingValidated) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -30,6 +30,7 @@ import io.element.android.features.messages.impl.utils.containsOnlyEmojis
|
||||
import io.element.android.libraries.androidutils.text.LinkifyHelper
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.utils.LocalUiTestMode
|
||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater
|
||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||
@@ -43,6 +44,9 @@ fun TimelineItemTextView(
|
||||
modifier: Modifier = Modifier,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {},
|
||||
) {
|
||||
// The View <-> Compose interop is not working well with Compose UI tests (it loops indefinitely), so we skip it in the UI test mode.
|
||||
if (LocalUiTestMode.current) return
|
||||
|
||||
val emojiOnly = content.formattedBody.toString() == content.body &&
|
||||
content.body.replace(" ", "").containsOnlyEmojis()
|
||||
val textStyle = when {
|
||||
|
||||
+35
-96
@@ -8,22 +8,17 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.event
|
||||
|
||||
import android.text.SpannedString
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -34,26 +29,19 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.hideFromAccessibility
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.features.messages.impl.timeline.components.ATimelineItemEventRow
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContentProvider
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.protection.ProtectedView
|
||||
import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent
|
||||
import io.element.android.features.messages.impl.timeline.util.handleAsyncImageStateChange
|
||||
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
|
||||
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||
import io.element.android.libraries.designsystem.modifiers.roundedBackground
|
||||
@@ -62,11 +50,11 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT
|
||||
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH
|
||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
|
||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
|
||||
@Composable
|
||||
fun TimelineItemVideoView(
|
||||
@@ -75,15 +63,13 @@ fun TimelineItemVideoView(
|
||||
onContentClick: (() -> Unit)?,
|
||||
onLongClick: (() -> Unit)?,
|
||||
onShowContentClick: () -> Unit,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentValidationState: ContentValidationState = remember { DefaultContentValidationState() },
|
||||
) {
|
||||
val isTalkbackActive = isTalkbackActive()
|
||||
val a11yLabel = stringResource(CommonStrings.common_video)
|
||||
val description = content.caption?.let { "$a11yLabel: $it" } ?: a11yLabel
|
||||
Column(modifier = modifier) {
|
||||
Column(modifier = modifier.wrapContentWidth(Alignment.CenterHorizontally)) {
|
||||
val containerModifier = if (content.showCaption) {
|
||||
Modifier
|
||||
.padding(top = 6.dp)
|
||||
@@ -91,6 +77,9 @@ fun TimelineItemVideoView(
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
val eventContentValidation by contentValidationState.collectOverallState()
|
||||
val isContentBeingValidated = !eventContentValidation.isValidated()
|
||||
TimelineItemAspectRatioBox(
|
||||
modifier = containerModifier.blurHashBackground(content.blurHash, alpha = 0.9f),
|
||||
aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent),
|
||||
@@ -127,48 +116,35 @@ fun TimelineItemVideoView(
|
||||
contentScale = ContentScale.Crop,
|
||||
alignment = Alignment.Center,
|
||||
contentDescription = description,
|
||||
onState = { isLoaded = it is AsyncImagePainter.State.Success },
|
||||
onState = { state ->
|
||||
val url = content.thumbnailSource?.safeUrl
|
||||
if (url != null) {
|
||||
handleAsyncImageStateChange(
|
||||
state = state,
|
||||
onLoaded = { isLoaded = true },
|
||||
updateContentValidationState = { contentValidationState.update(url, it) },
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier.roundedBackground(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
imageVector = CompoundIcons.PlaySolid(),
|
||||
contentDescription = stringResource(id = CommonStrings.a11y_play),
|
||||
colorFilter = ColorFilter.tint(Color.White),
|
||||
modifier = Modifier.semantics { hideFromAccessibility() }
|
||||
)
|
||||
if (!isContentBeingValidated) {
|
||||
Box(
|
||||
modifier = Modifier.roundedBackground(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
imageVector = CompoundIcons.PlaySolid(),
|
||||
contentDescription = stringResource(id = CommonStrings.a11y_play),
|
||||
colorFilter = ColorFilter.tint(Color.White),
|
||||
modifier = Modifier.semantics { hideFromAccessibility() }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (content.showCaption) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val caption = if (LocalInspectionMode.current) {
|
||||
SpannedString(content.caption)
|
||||
} else {
|
||||
content.formattedCaption ?: SpannedString(content.caption)
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides ElementTheme.colors.textPrimary,
|
||||
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular,
|
||||
) {
|
||||
val aspectRatio = content.aspectRatio ?: DEFAULT_ASPECT_RATIO
|
||||
EditorStyledText(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 4.dp) // This is (12.dp - 8.dp) contentPadding from CommonLayout
|
||||
.widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio),
|
||||
text = caption,
|
||||
onLinkClickedListener = onLinkClick,
|
||||
onLinkLongClickedListener = onLinkLongClick,
|
||||
style = ElementRichTextEditorStyle.textStyle(),
|
||||
releaseOnDetach = false,
|
||||
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,9 +157,6 @@ internal fun TimelineItemVideoViewPreview(@PreviewParameter(TimelineItemVideoCon
|
||||
onShowContentClick = {},
|
||||
onContentClick = {},
|
||||
onLongClick = {},
|
||||
onLinkClick = {},
|
||||
onLinkLongClick = {},
|
||||
onContentLayoutChange = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -196,39 +169,5 @@ internal fun TimelineItemVideoViewHideMediaContentPreview() = ElementPreview {
|
||||
onShowContentClick = {},
|
||||
onContentClick = {},
|
||||
onLongClick = {},
|
||||
onLinkClick = {},
|
||||
onLinkLongClick = {},
|
||||
onContentLayoutChange = {},
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineVideoWithCaptionRowPreview() = ElementPreview {
|
||||
Column {
|
||||
sequenceOf(false, true).forEach { isMine ->
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
isMine = isMine,
|
||||
content = aTimelineItemVideoContent().copy(
|
||||
filename = "video.mp4",
|
||||
caption = "A long caption that may wrap into several lines",
|
||||
aspectRatio = 2.5f,
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
),
|
||||
)
|
||||
}
|
||||
ATimelineItemEventRow(
|
||||
event = aTimelineItemEvent(
|
||||
isMine = false,
|
||||
content = aTimelineItemVideoContent().copy(
|
||||
filename = "video.mp4",
|
||||
caption = "Video with null aspect ratio",
|
||||
aspectRatio = null,
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+12
-6
@@ -51,6 +51,7 @@ import io.element.android.libraries.designsystem.theme.components.CircularProgre
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
|
||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent
|
||||
@@ -64,6 +65,7 @@ fun TimelineItemVoiceView(
|
||||
content: TimelineItemVoiceContent,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentValidationValue: ContentValidationValue = ContentValidationValue.Valid,
|
||||
) {
|
||||
fun playPause() {
|
||||
state.eventSink(VoiceMessageEvent.PlayPause)
|
||||
@@ -103,12 +105,16 @@ fun TimelineItemVoiceView(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (!isTalkbackActive()) {
|
||||
when (state.buttonType) {
|
||||
VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause)
|
||||
VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause)
|
||||
VoiceMessageState.ButtonType.Downloading -> ProgressButton()
|
||||
VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause)
|
||||
VoiceMessageState.ButtonType.Disabled -> PlayButton(onClick = {}, enabled = false)
|
||||
if (contentValidationValue.isValid()) {
|
||||
when (state.buttonType) {
|
||||
VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause)
|
||||
VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause)
|
||||
VoiceMessageState.ButtonType.Downloading -> ProgressButton()
|
||||
VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause)
|
||||
VoiceMessageState.ButtonType.Disabled -> PlayButton(onClick = {}, enabled = false)
|
||||
}
|
||||
} else {
|
||||
ProgressButton(displayImmediately = true)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
||||
+12
@@ -9,6 +9,7 @@
|
||||
package io.element.android.features.messages.impl.timeline.components.layout
|
||||
|
||||
import android.text.Layout
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -23,6 +24,7 @@ import androidx.compose.ui.unit.DpOffset
|
||||
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.libraries.designsystem.utils.LocalUiTestMode
|
||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
@@ -41,6 +43,7 @@ import kotlin.math.roundToInt
|
||||
* @param shrinkContent Whether the content should be shrunk to fit the available width or not. Defaults to `false`.
|
||||
* @param content The 'content' component of the layout.
|
||||
*/
|
||||
@Suppress("ContentSlotReused") // Since we added an exception for `LocalUiTestMode`, detekt thinks the layout can change in runtime: it won't
|
||||
@Composable
|
||||
fun ContentAvoidingLayout(
|
||||
overlay: @Composable () -> Unit,
|
||||
@@ -52,6 +55,15 @@ fun ContentAvoidingLayout(
|
||||
) {
|
||||
val scope = remember { ContentAvoidingLayoutScopeInstance() }
|
||||
|
||||
// Custom layouts don't seem to work well with Compose UI tests (they crash), so we use a Column instead when running in test mode.
|
||||
if (LocalUiTestMode.current) {
|
||||
Column {
|
||||
scope.content()
|
||||
overlay()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Layout(
|
||||
modifier = modifier,
|
||||
content = {
|
||||
|
||||
-2
@@ -19,8 +19,6 @@ open class TimelineItemAudioContentProvider : PreviewParameterProvider<TimelineI
|
||||
aTimelineItemAudioContent("A sound.mp3"),
|
||||
aTimelineItemAudioContent("A bigger name sound.mp3"),
|
||||
aTimelineItemAudioContent("An even bigger bigger bigger bigger bigger bigger bigger sound name which doesn't fit.mp3"),
|
||||
aTimelineItemAudioContent(caption = "A caption"),
|
||||
aTimelineItemAudioContent(caption = "An even bigger bigger bigger bigger bigger bigger bigger caption"),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+19
@@ -15,6 +15,9 @@ import kotlin.time.Duration
|
||||
@Immutable
|
||||
sealed interface TimelineItemEventContent {
|
||||
val type: String
|
||||
|
||||
val isMedia: Boolean
|
||||
get() = this is TimelineItemEventContentWithAttachment || this is TimelineItemGalleryContent || this is TimelineItemAttachmentsContent
|
||||
}
|
||||
|
||||
interface TimelineItemEventMutableContent {
|
||||
@@ -115,6 +118,13 @@ fun TimelineItemEventContent.captionOrNull(): String? = when (this) {
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun TimelineItemEventContent.formattedCaptionOrNull(): CharSequence? = when (this) {
|
||||
is TimelineItemEventContentWithAttachment -> formattedCaption
|
||||
is TimelineItemGalleryContent -> formattedCaption
|
||||
is TimelineItemAttachmentsContent -> formattedCaption
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun TimelineItemEventContentWithAttachment.duration(): Duration? {
|
||||
return when (this) {
|
||||
is TimelineItemAudioContent -> duration
|
||||
@@ -123,3 +133,12 @@ fun TimelineItemEventContentWithAttachment.duration(): Duration? {
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun TimelineItemEventContentWithAttachment.blurHash(): String? {
|
||||
return when (this) {
|
||||
is TimelineItemImageContent -> blurhash
|
||||
is TimelineItemVideoContent -> blurHash
|
||||
is TimelineItemStickerContent -> blurhash
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -135,6 +135,7 @@ fun anAttachmentItem(
|
||||
fileSize: Long? = 1_000_000L,
|
||||
formattedFileSize: String = "1MB",
|
||||
mimeType: String? = null,
|
||||
mediaSource: MediaSource = MediaSource(url = "", json = ""),
|
||||
thumbnailSource: MediaSource? = null,
|
||||
hasThumbnail: Boolean = false,
|
||||
) = AttachmentItem(
|
||||
@@ -143,7 +144,7 @@ fun anAttachmentItem(
|
||||
hasThumbnail -> "image/jpeg"
|
||||
else -> "application/$fileExtension"
|
||||
},
|
||||
mediaSource = MediaSource(url = "", json = ""),
|
||||
mediaSource = mediaSource,
|
||||
thumbnailSource = thumbnailSource ?: if (hasThumbnail) MediaSource(url = "", json = "") else null,
|
||||
fileSize = fileSize,
|
||||
formattedFileSize = formattedFileSize,
|
||||
|
||||
-2
@@ -18,8 +18,6 @@ open class TimelineItemFileContentProvider : PreviewParameterProvider<TimelineIt
|
||||
aTimelineItemFileContent(),
|
||||
aTimelineItemFileContent("A bigger name file.pdf"),
|
||||
aTimelineItemFileContent("An even bigger bigger bigger bigger bigger bigger bigger file name which doesn't fit.pdf"),
|
||||
aTimelineItemFileContent(caption = "A caption"),
|
||||
aTimelineItemFileContent(caption = "An even bigger bigger bigger bigger bigger bigger bigger caption"),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+7
-3
@@ -20,6 +20,7 @@ open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineI
|
||||
aTimelineItemVideoContent(),
|
||||
aTimelineItemVideoContent(aspectRatio = 1.0f),
|
||||
aTimelineItemVideoContent(aspectRatio = 1.5f),
|
||||
aTimelineItemVideoContent(thumbnailMediaSource = MediaSource("https://example.com/thumbnail.jpg")),
|
||||
aTimelineItemVideoContent(blurhash = null),
|
||||
)
|
||||
}
|
||||
@@ -27,17 +28,20 @@ open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineI
|
||||
fun aTimelineItemVideoContent(
|
||||
aspectRatio: Float = 0.5f,
|
||||
blurhash: String? = A_BLUR_HASH,
|
||||
caption: String? = null,
|
||||
mediaSource: MediaSource = MediaSource(""),
|
||||
thumbnailMediaSource: MediaSource? = null,
|
||||
) = TimelineItemVideoContent(
|
||||
filename = "Video.mp4",
|
||||
fileSize = 14 * 1024 * 1024L,
|
||||
caption = null,
|
||||
caption = caption,
|
||||
formattedCaption = null,
|
||||
isEdited = false,
|
||||
thumbnailSource = null,
|
||||
thumbnailSource = thumbnailMediaSource,
|
||||
blurHash = blurhash,
|
||||
aspectRatio = aspectRatio,
|
||||
duration = 100.milliseconds,
|
||||
mediaSource = MediaSource(""),
|
||||
mediaSource = mediaSource,
|
||||
width = 150,
|
||||
height = 300,
|
||||
thumbnailWidth = 150,
|
||||
|
||||
+7
@@ -9,7 +9,14 @@
|
||||
package io.element.android.features.messages.impl.timeline.protection
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
|
||||
sealed interface TimelineProtectionEvent {
|
||||
data class ShowContent(val eventId: EventId?) : TimelineProtectionEvent
|
||||
data class ValidateContent(
|
||||
val eventId: EventId,
|
||||
val mediaSources: List<MediaSource>,
|
||||
val validationState: ContentValidationState,
|
||||
) : TimelineProtectionEvent
|
||||
}
|
||||
|
||||
+13
-4
@@ -12,23 +12,28 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableStateSetOf
|
||||
import androidx.compose.runtime.remember
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.features.contentscanner.api.ContentScannerService
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.mapState
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaPreviewService
|
||||
import io.element.android.libraries.matrix.api.media.isPreviewEnabled
|
||||
import io.element.android.libraries.matrix.api.room.BaseRoom
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
|
||||
@SingleIn(RoomScope::class)
|
||||
@Inject
|
||||
class TimelineProtectionPresenter(
|
||||
private val mediaPreviewService: MediaPreviewService,
|
||||
private val room: BaseRoom,
|
||||
private val contentScannerService: ContentScannerService,
|
||||
) : Presenter<TimelineProtectionState> {
|
||||
private val allowedEvents = mutableStateOf<Set<EventId>>(setOf())
|
||||
private val allowedEvents = mutableStateSetOf<EventId>()
|
||||
|
||||
@Composable
|
||||
override fun present(): TimelineProtectionState {
|
||||
@@ -36,13 +41,14 @@ class TimelineProtectionPresenter(
|
||||
mediaPreviewService.mediaPreviewConfigFlow.mapState { config -> config.mediaPreviewValue }
|
||||
}.collectAsState()
|
||||
val roomInfo = room.roomInfoFlow.collectAsState()
|
||||
|
||||
val protectionState by remember {
|
||||
derivedStateOf {
|
||||
val isPreviewEnabled = mediaPreviewValue.value.isPreviewEnabled(roomInfo.value.joinRule)
|
||||
if (isPreviewEnabled) {
|
||||
ProtectionState.RenderAll
|
||||
} else {
|
||||
ProtectionState.RenderOnly(eventIds = allowedEvents.value.toImmutableSet())
|
||||
ProtectionState.RenderOnly(eventIds = allowedEvents.toImmutableSet())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,7 +56,10 @@ class TimelineProtectionPresenter(
|
||||
fun handleEvent(event: TimelineProtectionEvent) {
|
||||
when (event) {
|
||||
is TimelineProtectionEvent.ShowContent -> {
|
||||
allowedEvents.value = allowedEvents.value + setOfNotNull(event.eventId)
|
||||
allowedEvents += setOfNotNull(event.eventId)
|
||||
}
|
||||
is TimelineProtectionEvent.ValidateContent -> {
|
||||
contentScannerService.scan(event.eventId, event.mediaSources, event.validationState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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.util
|
||||
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import io.element.android.libraries.matrix.api.exception.ClientException
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import okio.IOException
|
||||
|
||||
fun handleAsyncImageStateChange(
|
||||
state: AsyncImagePainter.State,
|
||||
onLoaded: () -> Unit,
|
||||
updateContentValidationState: ((ContentValidationValue) -> Unit)?,
|
||||
) {
|
||||
if (state is AsyncImagePainter.State.Success) {
|
||||
onLoaded()
|
||||
}
|
||||
|
||||
val updatedContentValidationValue = when (state) {
|
||||
is AsyncImagePainter.State.Empty -> ContentValidationValue.Unknown
|
||||
is AsyncImagePainter.State.Loading -> ContentValidationValue.Loading
|
||||
is AsyncImagePainter.State.Success -> ContentValidationValue.Valid
|
||||
is AsyncImagePainter.State.Error -> {
|
||||
when (val cause = state.result.throwable) {
|
||||
is ClientException.ContentScanner if cause.reason.isDangerous() -> ContentValidationValue.Invalid
|
||||
is IOException -> {
|
||||
// Use `Unknown` instead of `Invalid` to allow retrying the download later
|
||||
ContentValidationValue.Unknown
|
||||
}
|
||||
else -> ContentValidationValue.UnrecoverableError(cause)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateContentValidationState?.invoke(updatedContentValidationValue)
|
||||
}
|
||||
+3
@@ -37,6 +37,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
|
||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.InMemoryEventContentValidationCache
|
||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
|
||||
@@ -315,6 +316,8 @@ class MessageComposerPresenterSlashCommandTest {
|
||||
notificationConversationService = notificationConversationService,
|
||||
slashCommandService = slashCommandService,
|
||||
featureFlagService = featureFlagService,
|
||||
contentScannerService = { _, _, _ -> },
|
||||
contentValidationCache = InMemoryEventContentValidationCache(),
|
||||
).apply {
|
||||
isTesting = true
|
||||
showTextFormatting = isRichTextEditorEnabled
|
||||
|
||||
+3
@@ -75,6 +75,7 @@ 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.ui.media.contentvalidation.InMemoryEventContentValidationCache
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
|
||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||
@@ -1649,6 +1650,8 @@ class MessageComposerPresenterTest : RobolectricTest() {
|
||||
notificationConversationService = notificationConversationService,
|
||||
slashCommandService = slashCommandService,
|
||||
featureFlagService = featureFlagService,
|
||||
contentScannerService = { _, _, _ -> },
|
||||
contentValidationCache = InMemoryEventContentValidationCache(),
|
||||
).apply {
|
||||
isTesting = true
|
||||
showTextFormatting = isRichTextEditorEnabled
|
||||
|
||||
+5
@@ -19,6 +19,8 @@ import io.element.android.features.messages.impl.timeline.components.MessageShie
|
||||
import io.element.android.features.messages.impl.timeline.components.aCriticalShield
|
||||
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.protection.TimelineProtectionState
|
||||
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
|
||||
import io.element.android.features.messages.impl.typing.aTypingNotificationState
|
||||
import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager
|
||||
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
|
||||
@@ -28,6 +30,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.architecture.Presenter
|
||||
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
|
||||
@@ -1585,6 +1588,7 @@ class TimelinePresenterTest {
|
||||
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
|
||||
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
|
||||
markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead { _, _ -> },
|
||||
timelineProtectionPresenter: Presenter<TimelineProtectionState> = { aTimelineProtectionState() },
|
||||
): TimelinePresenter {
|
||||
return TimelinePresenter(
|
||||
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
|
||||
@@ -1605,6 +1609,7 @@ class TimelinePresenterTest {
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
liveLocationShareManager = liveLocationShareManager,
|
||||
markAsFullyRead = markAsFullyRead,
|
||||
timelineProtectionPresenter = timelineProtectionPresenter,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -21,7 +21,7 @@ import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
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
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemUnknownContent
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
@@ -67,7 +67,7 @@ class TimelineViewTest : RobolectricTest() {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
setTimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())),
|
||||
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemTextContent())),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
@@ -79,7 +79,7 @@ class TimelineViewTest : RobolectricTest() {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
setTimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())),
|
||||
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemTextContent())),
|
||||
isLive = true,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
@@ -98,7 +98,7 @@ class TimelineViewTest : RobolectricTest() {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
setTimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())),
|
||||
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemTextContent())),
|
||||
isLive = false,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
@@ -133,7 +133,7 @@ class TimelineViewTest : RobolectricTest() {
|
||||
timelineItems = persistentListOf<TimelineItem>(
|
||||
aTimelineItemEvent(
|
||||
// Do not use a Text because EditorStyledText cannot be used in UI test.
|
||||
content = aTimelineItemImageContent(),
|
||||
content = aTimelineItemTextContent(),
|
||||
messageShield = MessageShield.UnverifiedIdentity(true),
|
||||
),
|
||||
),
|
||||
@@ -155,7 +155,7 @@ class TimelineViewTest : RobolectricTest() {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
setTimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())),
|
||||
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemTextContent())),
|
||||
isLive = false,
|
||||
eventSink = eventsRecorder,
|
||||
messageShield = aCriticalShield(),
|
||||
|
||||
+47
@@ -9,19 +9,28 @@
|
||||
package io.element.android.features.messages.impl.timeline.protection
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.contentscanner.api.ContentScannerService
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaPreviewConfig
|
||||
import io.element.android.libraries.matrix.api.media.MediaPreviewService
|
||||
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.room.BaseRoom
|
||||
import io.element.android.libraries.matrix.api.room.join.JoinRule
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.media.FakeMediaPreviewService
|
||||
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
||||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -87,11 +96,49 @@ class TimelineProtectionPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `present - validate media scans the media source`() = runTest {
|
||||
val url = "https://example.com/media"
|
||||
|
||||
val mediaPreviewConfig = MediaPreviewConfig(mediaPreviewValue = MediaPreviewValue.Private, hideInviteAvatar = false)
|
||||
val mediaPreviewService = FakeMediaPreviewService(mediaPreviewConfigFlow = MutableStateFlow(mediaPreviewConfig))
|
||||
val room = FakeBaseRoom(initialRoomInfo = aRoomInfo(joinRule = JoinRule.Invite), roomCoroutineScope = backgroundScope)
|
||||
val contentScannerService = lambdaRecorder { _: EventId, _: List<MediaSource>, state: ContentValidationState ->
|
||||
state.update(url, ContentValidationValue.Valid)
|
||||
}
|
||||
val presenter = createPresenter(
|
||||
mediaPreviewService = mediaPreviewService,
|
||||
room = room,
|
||||
contentScannerService = contentScannerService,
|
||||
)
|
||||
presenter.test {
|
||||
val validationState = DefaultContentValidationState(initial = mapOf(url to ContentValidationValue.Unknown))
|
||||
assertThat(validationState.getCurrentOverallState().isValid()).isFalse()
|
||||
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(
|
||||
TimelineProtectionEvent.ValidateContent(
|
||||
eventId = AN_EVENT_ID,
|
||||
mediaSources = listOf(MediaSource(url)),
|
||||
validationState = validationState
|
||||
)
|
||||
)
|
||||
|
||||
runCurrent()
|
||||
|
||||
contentScannerService.assertions().isCalledOnce()
|
||||
assertThat(validationState.getCurrentOverallState().isValid()).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPresenter(
|
||||
room: BaseRoom = FakeBaseRoom(),
|
||||
mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(),
|
||||
contentScannerService: ContentScannerService = ContentScannerService { _: EventId, _: List<MediaSource>, _: ContentValidationState -> },
|
||||
) = TimelineProtectionPresenter(
|
||||
mediaPreviewService = mediaPreviewService,
|
||||
room = room,
|
||||
contentScannerService = contentScannerService,
|
||||
)
|
||||
}
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* 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.util
|
||||
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import coil3.request.ErrorResult
|
||||
import coil3.request.SuccessResult
|
||||
import io.element.android.libraries.matrix.api.exception.ClientException
|
||||
import io.element.android.libraries.matrix.api.exception.ContentScannerErrorReason
|
||||
import io.element.android.libraries.matrix.api.exception.ContentScannerErrorReason.MCS_MEDIA_NOT_CLEAN
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.mockk.mockk
|
||||
import org.junit.Test
|
||||
import java.io.FileNotFoundException
|
||||
|
||||
class AsyncImageStateHandlerTest {
|
||||
@Test
|
||||
fun `test Empty state sets the content validation state to Unknown`() {
|
||||
val updateContentValidationState = lambdaRecorder<ContentValidationValue, Unit> {}
|
||||
handleAsyncImageStateChange(
|
||||
state = AsyncImagePainter.State.Empty,
|
||||
onLoaded = {},
|
||||
updateContentValidationState = updateContentValidationState,
|
||||
)
|
||||
|
||||
updateContentValidationState.assertions().isCalledOnce().with(value(ContentValidationValue.Unknown))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test Loading state sets the content validation state to Loading`() {
|
||||
val updateContentValidationState = lambdaRecorder<ContentValidationValue, Unit> {}
|
||||
handleAsyncImageStateChange(
|
||||
state = AsyncImagePainter.State.Loading(null),
|
||||
onLoaded = {},
|
||||
updateContentValidationState = updateContentValidationState,
|
||||
)
|
||||
|
||||
updateContentValidationState.assertions().isCalledOnce().with(value(ContentValidationValue.Loading))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test Success state sets the content validation state to Valid and triggers onLoaded callback`() {
|
||||
val updateContentValidationState = lambdaRecorder<ContentValidationValue, Unit> {}
|
||||
val onLoaded = lambdaRecorder<Unit> {}
|
||||
handleAsyncImageStateChange(
|
||||
state = AsyncImagePainter.State.Success(mockk<Painter>(), SuccessResult(mockk(), mockk())),
|
||||
onLoaded = onLoaded,
|
||||
updateContentValidationState = updateContentValidationState,
|
||||
)
|
||||
|
||||
onLoaded.assertions().isCalledOnce()
|
||||
updateContentValidationState.assertions().isCalledOnce().with(value(ContentValidationValue.Valid))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test Failure state sets the content validation state to Invalid if the error matches`() {
|
||||
val updateContentValidationState = lambdaRecorder<ContentValidationValue, Unit> {}
|
||||
val onLoaded = lambdaRecorder<Unit> {}
|
||||
val mediaNotCleanError = ClientException.ContentScanner("dangerous", MCS_MEDIA_NOT_CLEAN)
|
||||
val forbiddenMimeTypeError = ClientException.ContentScanner("dangerous", ContentScannerErrorReason.MCS_MIME_TYPE_FORBIDDEN)
|
||||
handleAsyncImageStateChange(
|
||||
state = AsyncImagePainter.State.Error(null, ErrorResult(null, mockk(), mediaNotCleanError)),
|
||||
onLoaded = onLoaded,
|
||||
updateContentValidationState = updateContentValidationState,
|
||||
)
|
||||
|
||||
handleAsyncImageStateChange(
|
||||
state = AsyncImagePainter.State.Error(null, ErrorResult(null, mockk(), forbiddenMimeTypeError)),
|
||||
onLoaded = onLoaded,
|
||||
updateContentValidationState = updateContentValidationState,
|
||||
)
|
||||
|
||||
onLoaded.assertions().isNeverCalled()
|
||||
updateContentValidationState.assertions()
|
||||
.isCalledExactly(2)
|
||||
.withSequence(
|
||||
listOf(value(ContentValidationValue.Invalid)),
|
||||
listOf(value(ContentValidationValue.Invalid))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test Failure state sets the content validation state to Uknown if the error does not match a content scanner one`() {
|
||||
val updateContentValidationState = lambdaRecorder<ContentValidationValue, Unit> {}
|
||||
val onLoaded = lambdaRecorder<Unit> {}
|
||||
val error = FileNotFoundException("File not found")
|
||||
handleAsyncImageStateChange(
|
||||
state = AsyncImagePainter.State.Error(null, ErrorResult(null, mockk(), error)),
|
||||
onLoaded = onLoaded,
|
||||
updateContentValidationState = updateContentValidationState,
|
||||
)
|
||||
|
||||
onLoaded.assertions().isNeverCalled()
|
||||
updateContentValidationState.assertions().isCalledOnce().with(value(ContentValidationValue.Unknown))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user