mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 12:07:28 +00:00
Merge branch 'develop' into feature/valere/timeline_active_call_rendering
This commit is contained in:
+1
@@ -116,6 +116,7 @@ class DefaultContentValidationState(
|
||||
|
||||
private fun calculateOverallState(states: Map<String, ContentValidationValue>): ContentValidationValue {
|
||||
return when {
|
||||
states.values.isEmpty() -> ContentValidationValue.Unknown
|
||||
states.values.any { it is ContentValidationValue.Invalid } -> ContentValidationValue.Invalid
|
||||
states.values.any { it is ContentValidationValue.Loading } -> ContentValidationValue.Loading
|
||||
states.values.any { it is ContentValidationValue.UnrecoverableError } -> states.values.first { it is ContentValidationValue.UnrecoverableError }
|
||||
|
||||
+4
-2
@@ -26,8 +26,10 @@ val LocalEventContentValidationState = staticCompositionLocalOf<EventContentVali
|
||||
/**
|
||||
* A noop implementation of the [EventContentValidationCache] that immediately returns a successful validation state. This will be used in FOSS.
|
||||
*/
|
||||
class NoopEventContentValidationCache : EventContentValidationCache {
|
||||
override operator fun get(eventId: EventId): ContentValidationState = noopValue
|
||||
class NoopEventContentValidationCache(
|
||||
private val provided: ContentValidationState = noopValue,
|
||||
) : EventContentValidationCache {
|
||||
override operator fun get(eventId: EventId): ContentValidationState = provided
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -39,6 +39,7 @@ dependencies {
|
||||
|
||||
implementation(projects.features.enterprise.api)
|
||||
implementation(projects.features.viewfolder.api)
|
||||
implementation(projects.features.contentscanner.api)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.audio.api)
|
||||
@@ -48,6 +49,7 @@ dependencies {
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.featureflag.api)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.matrixmedia.api)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.uiUtils)
|
||||
|
||||
+15
@@ -44,6 +44,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageT
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
|
||||
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.ui.media.contentvalidation.EventContentValidationCache
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
|
||||
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
||||
@@ -54,6 +56,7 @@ class EventItemFactory(
|
||||
private val fileSizeFormatter: FileSizeFormatter,
|
||||
private val fileExtensionExtractor: FileExtensionExtractor,
|
||||
private val dateFormatter: DateFormatter,
|
||||
private val contentValidationCache: EventContentValidationCache,
|
||||
) {
|
||||
fun create(
|
||||
currentTimelineItem: MatrixTimelineItem.Event,
|
||||
@@ -67,6 +70,8 @@ class EventItemFactory(
|
||||
timestamp = currentTimelineItem.event.timestamp,
|
||||
mode = DateFormatterMode.Full,
|
||||
)
|
||||
val validationState = currentTimelineItem.eventId?.let { contentValidationCache[it] }
|
||||
?: NoopContentValidationState()
|
||||
return when (val content = event.content) {
|
||||
is CallNotifyContent,
|
||||
is FailedToParseMessageLikeContent,
|
||||
@@ -117,6 +122,7 @@ class EventItemFactory(
|
||||
mediaSource = c.source,
|
||||
thumbnailSource = c.info?.thumbnailSource,
|
||||
blurHash = c.info?.blurhash,
|
||||
validationState = validationState,
|
||||
)
|
||||
}
|
||||
is GalleryItemType.Video -> {
|
||||
@@ -138,6 +144,7 @@ class EventItemFactory(
|
||||
mediaSource = c.source,
|
||||
thumbnailSource = c.info?.thumbnailSource,
|
||||
blurHash = c.info?.blurhash,
|
||||
validationState = validationState,
|
||||
)
|
||||
}
|
||||
is GalleryItemType.Audio -> {
|
||||
@@ -156,6 +163,7 @@ class EventItemFactory(
|
||||
dateSentFull = dateSentFull,
|
||||
),
|
||||
mediaSource = c.source,
|
||||
validationState = validationState,
|
||||
)
|
||||
}
|
||||
is GalleryItemType.File -> {
|
||||
@@ -174,6 +182,7 @@ class EventItemFactory(
|
||||
dateSentFull = dateSentFull,
|
||||
),
|
||||
mediaSource = c.source,
|
||||
validationState = validationState,
|
||||
)
|
||||
}
|
||||
is GalleryItemType.Other -> null
|
||||
@@ -200,6 +209,7 @@ class EventItemFactory(
|
||||
duration = null,
|
||||
),
|
||||
mediaSource = type.source,
|
||||
validationState = validationState,
|
||||
))
|
||||
is FileMessageType -> listOf(MediaItem.File(
|
||||
id = currentTimelineItem.uniqueId,
|
||||
@@ -221,6 +231,7 @@ class EventItemFactory(
|
||||
duration = null,
|
||||
),
|
||||
mediaSource = type.source,
|
||||
validationState = validationState,
|
||||
// TODO We may want to add a thumbnailSource and set it to type.info?.thumbnailSource
|
||||
))
|
||||
is ImageMessageType -> listOf(MediaItem.Image(
|
||||
@@ -245,6 +256,7 @@ class EventItemFactory(
|
||||
mediaSource = type.source,
|
||||
thumbnailSource = type.info?.thumbnailSource,
|
||||
blurHash = type.info?.blurhash,
|
||||
validationState = validationState,
|
||||
))
|
||||
is StickerMessageType -> listOf(MediaItem.Image(
|
||||
id = currentTimelineItem.uniqueId,
|
||||
@@ -268,6 +280,7 @@ class EventItemFactory(
|
||||
mediaSource = type.source,
|
||||
thumbnailSource = type.info?.thumbnailSource,
|
||||
blurHash = type.info?.blurhash,
|
||||
validationState = validationState,
|
||||
))
|
||||
is VideoMessageType -> listOf(MediaItem.Video(
|
||||
id = currentTimelineItem.uniqueId,
|
||||
@@ -291,6 +304,7 @@ class EventItemFactory(
|
||||
mediaSource = type.source,
|
||||
thumbnailSource = type.info?.thumbnailSource,
|
||||
blurHash = type.info?.blurhash,
|
||||
validationState = validationState,
|
||||
))
|
||||
is VoiceMessageType -> listOf(MediaItem.Voice(
|
||||
id = currentTimelineItem.uniqueId,
|
||||
@@ -312,6 +326,7 @@ class EventItemFactory(
|
||||
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
|
||||
),
|
||||
mediaSource = type.source,
|
||||
validationState = validationState,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
+32
@@ -20,6 +20,7 @@ import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.features.contentscanner.api.ContentScannerService
|
||||
import io.element.android.libraries.androidutils.R
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
@@ -43,7 +44,11 @@ import io.element.android.libraries.mediaviewer.impl.model.eventId
|
||||
import io.element.android.libraries.mediaviewer.impl.model.mediaInfo
|
||||
import io.element.android.libraries.mediaviewer.impl.model.mediaPermissions
|
||||
import io.element.android.libraries.mediaviewer.impl.model.mediaSource
|
||||
import io.element.android.libraries.mediaviewer.impl.model.thumbnailSource
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@AssistedInject
|
||||
@@ -55,6 +60,7 @@ class MediaGalleryPresenter(
|
||||
private val mediaLoader: MatrixMediaLoader,
|
||||
private val localMediaActions: LocalMediaActions,
|
||||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
private val contentScannerService: ContentScannerService,
|
||||
) : Presenter<MediaGalleryState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
@@ -74,6 +80,17 @@ class MediaGalleryPresenter(
|
||||
|
||||
val groupedMediaItems by remember {
|
||||
mediaGalleryDataSource.groupedMediaItemsFlow()
|
||||
.onEach { groupedItems ->
|
||||
if (groupedItems is AsyncData.Success) {
|
||||
val items = groupedItems.data.getItems(mode)
|
||||
items.forEach { item ->
|
||||
if (item is MediaItem.Event) {
|
||||
val eventId = item.eventId() ?: return@forEach
|
||||
coroutineScope.validateMedia(eventId, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.collectAsState(AsyncData.Uninitialized)
|
||||
|
||||
@@ -220,6 +237,21 @@ class MediaGalleryPresenter(
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.validateMedia(eventId: EventId, mediaItem: MediaItem.Event) {
|
||||
launch {
|
||||
val thumbnailSource = mediaItem.thumbnailSource()
|
||||
val mediaSource = mediaItem.mediaSource()
|
||||
val contentValidationState = mediaItem.validationState
|
||||
val currentState = contentValidationState.overallStateFlow.first()
|
||||
|
||||
if (currentState.isLoading() || currentState.isValid()) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
contentScannerService.scan(eventId, listOfNotNull(thumbnailSource, mediaSource).distinct(), contentValidationState)
|
||||
}
|
||||
}
|
||||
|
||||
private fun mediaActionsError(throwable: Throwable): Int {
|
||||
return if (throwable is ActivityNotFoundException) {
|
||||
R.string.error_no_compatible_app_found
|
||||
|
||||
+26
-4
@@ -13,6 +13,8 @@ import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.components.media.WaveFormSamples
|
||||
import io.element.android.libraries.designsystem.preview.ROOM_NAME
|
||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
|
||||
import io.element.android.libraries.mediaviewer.impl.details.aMediaBottomSheetStateDetails
|
||||
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
|
||||
@@ -47,9 +49,20 @@ open class MediaGalleryStateProvider : PreviewParameterProvider<MediaGalleryStat
|
||||
aMediaItemImage(id = UniqueId("3")),
|
||||
aMediaItemVideo(id = UniqueId("4")),
|
||||
aMediaItemImage(id = UniqueId("5")),
|
||||
aMediaItemImage(id = UniqueId("6")),
|
||||
aMediaItemImage(id = UniqueId("7")),
|
||||
aMediaItemImage(id = UniqueId("8")),
|
||||
aMediaItemImage(
|
||||
id = UniqueId("6"),
|
||||
validationState = NoopContentValidationState(ContentValidationValue.Loading),
|
||||
blurHash = "LKO2?U%2Tw=w]~RBVZRi};RPxuwH"
|
||||
),
|
||||
aMediaItemImage(id = UniqueId("7"), validationState = NoopContentValidationState(ContentValidationValue.Invalid)),
|
||||
aMediaItemImage(
|
||||
id = UniqueId("8"),
|
||||
validationState = NoopContentValidationState(
|
||||
ContentValidationValue.UnrecoverableError(
|
||||
IllegalStateException("Failed to validate media item")
|
||||
)
|
||||
)
|
||||
),
|
||||
aMediaItemImage(id = UniqueId("9")),
|
||||
aMediaItemLoadingIndicator(),
|
||||
).toImmutableList()
|
||||
@@ -65,7 +78,7 @@ open class MediaGalleryStateProvider : PreviewParameterProvider<MediaGalleryStat
|
||||
aGroupedMediaItems(
|
||||
fileItems = listOf(
|
||||
aMediaItemDateSeparator(id = UniqueId("0")),
|
||||
aMediaItemFile(id = UniqueId("1")),
|
||||
aMediaItemFile(id = UniqueId("1"), validationState = NoopContentValidationState(ContentValidationValue.Loading)),
|
||||
aMediaItemDateSeparator(
|
||||
id = UniqueId("2"),
|
||||
formattedDate = "September 2004",
|
||||
@@ -75,6 +88,15 @@ open class MediaGalleryStateProvider : PreviewParameterProvider<MediaGalleryStat
|
||||
id = UniqueId("5"),
|
||||
waveform = WaveFormSamples.realisticWaveForm,
|
||||
),
|
||||
aMediaItemAudio(id = UniqueId("6"), validationState = NoopContentValidationState(ContentValidationValue.Invalid)),
|
||||
aMediaItemAudio(
|
||||
id = UniqueId("7"),
|
||||
validationState = NoopContentValidationState(
|
||||
ContentValidationValue.UnrecoverableError(
|
||||
IllegalStateException("Failed to validate media item")
|
||||
)
|
||||
)
|
||||
),
|
||||
aMediaItemLoadingIndicator(),
|
||||
).toImmutableList()
|
||||
)
|
||||
|
||||
+73
-10
@@ -9,14 +9,18 @@
|
||||
package io.element.android.libraries.mediaviewer.impl.gallery
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
@@ -31,6 +35,8 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -49,11 +55,13 @@ import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubti
|
||||
import io.element.android.libraries.designsystem.background.OnboardingBackground
|
||||
import io.element.android.libraries.designsystem.components.BigIcon
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncFailure
|
||||
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
|
||||
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.LinearProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.SegmentedButton
|
||||
@@ -62,6 +70,9 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
|
||||
import io.element.android.libraries.mediaviewer.impl.R
|
||||
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
|
||||
import io.element.android.libraries.mediaviewer.impl.details.MediaDeleteConfirmationBottomSheet
|
||||
@@ -315,6 +326,14 @@ private fun MediaGalleryFilesList(
|
||||
key = { it.id() },
|
||||
contentType = { it::class.java },
|
||||
) { item ->
|
||||
val contentValidationState = remember(item is MediaItem.Event) {
|
||||
if (item is MediaItem.Event) {
|
||||
item.validationState
|
||||
} else {
|
||||
NoopContentValidationState()
|
||||
}
|
||||
}
|
||||
val contentValidationValue by contentValidationState.collectOverallState()
|
||||
when (item) {
|
||||
is MediaItem.File -> FileItemView(
|
||||
modifier = Modifier.animateItem(),
|
||||
@@ -323,6 +342,7 @@ private fun MediaGalleryFilesList(
|
||||
onLongClick = {
|
||||
eventSink(MediaGalleryEvent.OpenInfo(item))
|
||||
},
|
||||
contentValidationState = contentValidationValue,
|
||||
)
|
||||
is MediaItem.Audio -> AudioItemView(
|
||||
modifier = Modifier.animateItem(),
|
||||
@@ -331,6 +351,7 @@ private fun MediaGalleryFilesList(
|
||||
onLongClick = {
|
||||
eventSink(MediaGalleryEvent.OpenInfo(item))
|
||||
},
|
||||
contentValidationValue = contentValidationValue,
|
||||
)
|
||||
is MediaItem.Voice -> {
|
||||
val presenter: Presenter<VoiceMessageState> = presenterFactories.rememberPresenter(item)
|
||||
@@ -341,16 +362,17 @@ private fun MediaGalleryFilesList(
|
||||
onLongClick = {
|
||||
eventSink(MediaGalleryEvent.OpenInfo(item))
|
||||
},
|
||||
contentValidationValue = contentValidationValue,
|
||||
)
|
||||
}
|
||||
is MediaItem.Image,
|
||||
is MediaItem.Video -> {
|
||||
// Should not happen
|
||||
}
|
||||
is MediaItem.DateSeparator -> DateItemView(
|
||||
modifier = Modifier.animateItem(),
|
||||
item = item
|
||||
)
|
||||
is MediaItem.Image,
|
||||
is MediaItem.Video -> {
|
||||
// Should not happen
|
||||
}
|
||||
is MediaItem.LoadingIndicator -> LoadingMoreIndicator(
|
||||
modifier = Modifier.animateItem(),
|
||||
item = item,
|
||||
@@ -387,11 +409,48 @@ private fun MediaGalleryImageGrid(
|
||||
key = { it.id() },
|
||||
contentType = { it::class.java },
|
||||
) { item ->
|
||||
val blurHash = when (item) {
|
||||
is MediaItem.Image -> item.blurHash
|
||||
is MediaItem.Video -> item.blurHash
|
||||
else -> null
|
||||
}
|
||||
|
||||
val blurHashBackgroundModifier = if (blurHash != null) {
|
||||
Modifier.blurHashBackground(blurHash)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
val currentValidationState by when (item) {
|
||||
is MediaItem.Event -> item.validationState.collectOverallState()
|
||||
else -> remember { mutableStateOf(ContentValidationValue.Unknown) }
|
||||
}
|
||||
|
||||
when (item) {
|
||||
is MediaItem.DateSeparator -> DateItemView(
|
||||
modifier = Modifier.animateItem(),
|
||||
item = item,
|
||||
)
|
||||
is MediaItem.Event if !currentValidationState.isValidated() -> {
|
||||
Box(modifier = blurHashBackgroundModifier.aspectRatio(1f), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
is MediaItem.Event if currentValidationState.isInvalid() -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clickable { onItemClick(item) }
|
||||
.background(ElementTheme.colors.bgCriticalSubtle)
|
||||
.aspectRatio(1f),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(24.dp),
|
||||
imageVector = CompoundIcons.Error(),
|
||||
tint = ElementTheme.colors.iconCriticalPrimary,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
is MediaItem.Audio -> {
|
||||
// Should not happen
|
||||
}
|
||||
@@ -402,7 +461,7 @@ private fun MediaGalleryImageGrid(
|
||||
// Should not happen
|
||||
}
|
||||
is MediaItem.Image -> ImageItemView(
|
||||
modifier = Modifier.animateItem(),
|
||||
modifier = blurHashBackgroundModifier.animateItem(),
|
||||
image = item,
|
||||
onClick = { onItemClick(item) },
|
||||
onLongClick = {
|
||||
@@ -410,13 +469,17 @@ private fun MediaGalleryImageGrid(
|
||||
},
|
||||
)
|
||||
is MediaItem.Video -> VideoItemView(
|
||||
modifier = Modifier.animateItem(),
|
||||
modifier = blurHashBackgroundModifier.animateItem(),
|
||||
video = item,
|
||||
onClick = { onItemClick(item) },
|
||||
onLongClick = {
|
||||
eventSink(MediaGalleryEvent.OpenInfo(item))
|
||||
},
|
||||
)
|
||||
is MediaItem.DateSeparator -> DateItemView(
|
||||
modifier = Modifier.animateItem(),
|
||||
item = item,
|
||||
)
|
||||
is MediaItem.LoadingIndicator -> LoadingMoreIndicator(
|
||||
modifier = Modifier.animateItem(),
|
||||
item = item,
|
||||
|
||||
+30
-96
@@ -8,117 +8,38 @@
|
||||
|
||||
package io.element.android.libraries.mediaviewer.impl.gallery.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.core.extensions.withBrackets
|
||||
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.HorizontalDivider
|
||||
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
|
||||
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun AudioItemView(
|
||||
audio: MediaItem.Audio,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
contentValidationValue: ContentValidationValue,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
GalleryFileListItem(
|
||||
modifier = modifier,
|
||||
contentValidationValue = contentValidationValue,
|
||||
caption = audio.mediaInfo.caption,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
FilenameRow(
|
||||
audio = audio,
|
||||
GalleryFileListItemContent(
|
||||
name = audio.mediaInfo.filename,
|
||||
formattedSize = audio.mediaInfo.formattedFileSize,
|
||||
icon = CompoundIcons.Audio(),
|
||||
isValidating = contentValidationValue.isLoading(),
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
val caption = audio.mediaInfo.caption
|
||||
if (caption != null) {
|
||||
CaptionView(caption)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilenameRow(
|
||||
audio: MediaItem.Audio,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
color = ElementTheme.colors.bgSubtleSecondary,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
||||
)
|
||||
.onKeyboardContextMenuAction(onLongClick)
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = ElementTheme.colors.bgActionSecondaryRest,
|
||||
shape = CircleShape,
|
||||
)
|
||||
.size(32.dp)
|
||||
.padding(6.dp),
|
||||
imageVector = CompoundIcons.Audio(),
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = audio.mediaInfo.filename,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val formattedSize = audio.mediaInfo.formattedFileSize
|
||||
if (formattedSize.isNotEmpty()) {
|
||||
Text(
|
||||
text = formattedSize.withBrackets(),
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,9 +48,22 @@ private fun FilenameRow(
|
||||
internal fun AudioItemViewPreview(
|
||||
@PreviewParameter(MediaItemAudioProvider::class) audio: MediaItem.Audio,
|
||||
) = ElementPreview {
|
||||
AudioItemView(
|
||||
audio = audio,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
)
|
||||
val states = remember {
|
||||
listOf(
|
||||
ContentValidationValue.Valid,
|
||||
ContentValidationValue.Loading,
|
||||
ContentValidationValue.Invalid,
|
||||
ContentValidationValue.UnrecoverableError(Throwable("Unrecoverable error")),
|
||||
)
|
||||
}
|
||||
Column {
|
||||
for (state in states) {
|
||||
AudioItemView(
|
||||
contentValidationValue = state,
|
||||
audio = audio,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
-96
@@ -8,117 +8,38 @@
|
||||
|
||||
package io.element.android.libraries.mediaviewer.impl.gallery.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.core.extensions.withBrackets
|
||||
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.HorizontalDivider
|
||||
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
|
||||
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun FileItemView(
|
||||
file: MediaItem.File,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
contentValidationState: ContentValidationValue,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
GalleryFileListItem(
|
||||
modifier = modifier,
|
||||
contentValidationValue = contentValidationState,
|
||||
caption = file.mediaInfo.caption
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
FilenameRow(
|
||||
file = file,
|
||||
GalleryFileListItemContent(
|
||||
name = file.mediaInfo.filename,
|
||||
formattedSize = file.mediaInfo.formattedFileSize,
|
||||
icon = CompoundIcons.Attachment(),
|
||||
onClick = onClick,
|
||||
isValidating = contentValidationState.isLoading(),
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
val caption = file.mediaInfo.caption
|
||||
if (caption != null) {
|
||||
CaptionView(caption)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilenameRow(
|
||||
file: MediaItem.File,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
color = ElementTheme.colors.bgSubtleSecondary,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
||||
)
|
||||
.onKeyboardContextMenuAction(onLongClick)
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = ElementTheme.colors.bgActionSecondaryRest,
|
||||
shape = CircleShape,
|
||||
)
|
||||
.size(32.dp)
|
||||
.padding(6.dp),
|
||||
imageVector = CompoundIcons.Attachment(),
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = file.mediaInfo.filename,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val formattedSize = file.mediaInfo.formattedFileSize
|
||||
if (formattedSize.isNotEmpty()) {
|
||||
Text(
|
||||
text = formattedSize.withBrackets(),
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,9 +48,22 @@ private fun FilenameRow(
|
||||
internal fun FileItemViewPreview(
|
||||
@PreviewParameter(MediaItemFileProvider::class) file: MediaItem.File,
|
||||
) = ElementPreview {
|
||||
FileItemView(
|
||||
file = file,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
)
|
||||
val states = remember {
|
||||
listOf(
|
||||
ContentValidationValue.Valid,
|
||||
ContentValidationValue.Loading,
|
||||
ContentValidationValue.Invalid,
|
||||
ContentValidationValue.UnrecoverableError(Throwable("Unrecoverable error")),
|
||||
)
|
||||
}
|
||||
Column {
|
||||
for (state in states) {
|
||||
FileItemView(
|
||||
file = file,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
contentValidationState = state,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediaviewer.impl.gallery.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
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.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.core.extensions.withBrackets
|
||||
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
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.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
internal fun GalleryFileListItem(
|
||||
contentValidationValue: ContentValidationValue,
|
||||
caption: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
when (contentValidationValue) {
|
||||
ContentValidationValue.Invalid -> InvalidContentView(
|
||||
modifier = invalidContentModifier(),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
|
||||
)
|
||||
is ContentValidationValue.UnrecoverableError -> NotFoundContentView(
|
||||
modifier = invalidContentModifier(),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
|
||||
)
|
||||
else -> {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
if (caption != null) {
|
||||
CaptionView(caption)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun invalidContentModifier(): Modifier {
|
||||
return Modifier
|
||||
.border(width = 1.dp, color = ElementTheme.colors.borderCriticalSubtle, shape = RoundedCornerShape(12.dp))
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(ElementTheme.colors.bgCriticalSubtle)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun GalleryFileListItemContent(
|
||||
name: String,
|
||||
formattedSize: String,
|
||||
icon: ImageVector,
|
||||
isValidating: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
color = ElementTheme.colors.bgSubtleSecondary,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
.combinedClickable(
|
||||
onClick = onClick.takeIf { !isValidating } ?: {},
|
||||
onLongClick = onLongClick.takeIf { !isValidating } ?: {},
|
||||
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
||||
)
|
||||
.onKeyboardContextMenuAction(onLongClick)
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val iconModifier = Modifier
|
||||
.background(
|
||||
color = ElementTheme.colors.bgActionSecondaryRest,
|
||||
shape = CircleShape,
|
||||
)
|
||||
.size(32.dp)
|
||||
.padding(6.dp)
|
||||
|
||||
if (isValidating) {
|
||||
CircularProgressIndicator(
|
||||
modifier = iconModifier,
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
modifier = iconModifier,
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = name,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (formattedSize.isNotEmpty()) {
|
||||
Text(
|
||||
text = formattedSize.withBrackets(),
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -22,7 +22,7 @@ class MediaItemAudioProvider : PreviewParameterProvider<MediaItem.Audio> {
|
||||
caption = "A caption",
|
||||
),
|
||||
aMediaItemAudio(
|
||||
caption = loremIpsum,
|
||||
caption = loremIpsum.take(80),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ class MediaItemFileProvider : PreviewParameterProvider<MediaItem.File> {
|
||||
caption = "A caption",
|
||||
),
|
||||
aMediaItemFile(
|
||||
caption = loremIpsum,
|
||||
caption = loremIpsum.take(80),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ class MediaItemVoiceProvider : PreviewParameterProvider<MediaItem.Voice> {
|
||||
caption = "A caption",
|
||||
),
|
||||
aMediaItemVoice(
|
||||
caption = loremIpsum,
|
||||
caption = loremIpsum.take(80),
|
||||
),
|
||||
aMediaItemVoice(
|
||||
waveform = emptyList(),
|
||||
|
||||
+61
-31
@@ -45,10 +45,10 @@ import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenu
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||
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.mediaviewer.impl.model.MediaItem
|
||||
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVoice
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@@ -64,26 +64,20 @@ fun VoiceItemView(
|
||||
state: VoiceMessageState,
|
||||
voice: MediaItem.Voice,
|
||||
onLongClick: () -> Unit,
|
||||
contentValidationValue: ContentValidationValue,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
GalleryFileListItem(
|
||||
modifier = modifier,
|
||||
contentValidationValue = contentValidationValue,
|
||||
caption = voice.mediaInfo.caption,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
VoiceInfoRow(
|
||||
state = state,
|
||||
voice = voice,
|
||||
isValidating = contentValidationValue.isLoading(),
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
val caption = voice.mediaInfo.caption
|
||||
if (caption != null) {
|
||||
CaptionView(caption)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +85,7 @@ fun VoiceItemView(
|
||||
private fun VoiceInfoRow(
|
||||
state: VoiceMessageState,
|
||||
voice: MediaItem.Voice,
|
||||
isValidating: Boolean,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
fun playPause() {
|
||||
@@ -114,12 +109,21 @@ private fun VoiceInfoRow(
|
||||
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
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 (isValidating) {
|
||||
CustomIconButton(onClick = {}, enabled = false) {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = 2.dp,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column(
|
||||
@@ -128,7 +132,7 @@ private fun VoiceInfoRow(
|
||||
) {
|
||||
PlaybackSpeedButton(
|
||||
speed = state.playbackSpeed,
|
||||
onClick = { state.eventSink(VoiceMessageEvent.ChangePlaybackSpeed) },
|
||||
onClick = { state.eventSink(VoiceMessageEvent.ChangePlaybackSpeed) }.takeIf { !isValidating } ?: {},
|
||||
)
|
||||
Text(
|
||||
text = if (state.progress > 0f) state.time else voice.mediaInfo.duration ?: state.time,
|
||||
@@ -149,7 +153,7 @@ private fun VoiceInfoRow(
|
||||
onSeek = {
|
||||
state.eventSink(VoiceMessageEvent.Seek(it))
|
||||
},
|
||||
seekEnabled = true,
|
||||
seekEnabled = !isValidating,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -277,11 +281,24 @@ private fun CustomIconButton(
|
||||
internal fun VoiceItemViewPreview(
|
||||
@PreviewParameter(MediaItemVoiceProvider::class) voice: MediaItem.Voice,
|
||||
) = ElementPreview {
|
||||
VoiceItemView(
|
||||
state = aVoiceMessageState(),
|
||||
voice = voice,
|
||||
onLongClick = {},
|
||||
)
|
||||
val states = remember {
|
||||
listOf(
|
||||
ContentValidationValue.Valid,
|
||||
ContentValidationValue.Loading,
|
||||
ContentValidationValue.Invalid,
|
||||
ContentValidationValue.UnrecoverableError(Throwable("Unrecoverable error")),
|
||||
)
|
||||
}
|
||||
Column {
|
||||
for (state in states) {
|
||||
VoiceItemView(
|
||||
state = aVoiceMessageState(),
|
||||
voice = voice,
|
||||
onLongClick = {},
|
||||
contentValidationValue = state,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@@ -289,9 +306,22 @@ internal fun VoiceItemViewPreview(
|
||||
internal fun VoiceItemViewPlayPreview(
|
||||
@PreviewParameter(VoiceMessageStateProvider::class) state: VoiceMessageState,
|
||||
) = ElementPreview {
|
||||
VoiceItemView(
|
||||
state = state,
|
||||
voice = aMediaItemVoice(),
|
||||
onLongClick = {},
|
||||
)
|
||||
val validationState = remember {
|
||||
listOf(
|
||||
ContentValidationValue.Valid,
|
||||
ContentValidationValue.Loading,
|
||||
ContentValidationValue.Invalid,
|
||||
ContentValidationValue.UnrecoverableError(Throwable("Unrecoverable error")),
|
||||
)
|
||||
}
|
||||
Column {
|
||||
for (validationState in validationState) {
|
||||
VoiceItemView(
|
||||
state = state,
|
||||
voice = aMediaItemVoice(),
|
||||
onLongClick = {},
|
||||
contentValidationValue = validationState,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-23
@@ -254,29 +254,31 @@ private fun ExoPlayerMediaVideoView(
|
||||
},
|
||||
)
|
||||
}
|
||||
MediaPlayerControllerView(
|
||||
state = mediaPlayerControllerState,
|
||||
onTogglePlay = {
|
||||
autoHideController++
|
||||
exoPlayer.togglePlay()
|
||||
},
|
||||
onSeekChange = {
|
||||
autoHideController++
|
||||
mediaPlayerControllerState = mediaPlayerControllerState.copy(
|
||||
seekingToMillis = it.toLong(),
|
||||
)
|
||||
exoPlayer.seekToEnsurePlaying(it.toLong())
|
||||
},
|
||||
onToggleMute = {
|
||||
autoHideController++
|
||||
exoPlayer.volume = if (exoPlayer.volume == 1f) 0f else 1f
|
||||
},
|
||||
audioFocus = audioFocus,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = bottomPaddingInPixels.toDp()),
|
||||
)
|
||||
if (localMedia != null) {
|
||||
MediaPlayerControllerView(
|
||||
state = mediaPlayerControllerState,
|
||||
onTogglePlay = {
|
||||
autoHideController++
|
||||
exoPlayer.togglePlay()
|
||||
},
|
||||
onSeekChange = {
|
||||
autoHideController++
|
||||
mediaPlayerControllerState = mediaPlayerControllerState.copy(
|
||||
seekingToMillis = it.toLong(),
|
||||
)
|
||||
exoPlayer.seekToEnsurePlaying(it.toLong())
|
||||
},
|
||||
onToggleMute = {
|
||||
autoHideController++
|
||||
exoPlayer.volume = if (exoPlayer.volume == 1f) 0f else 1f
|
||||
},
|
||||
audioFocus = audioFocus,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = bottomPaddingInPixels.toDp()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(exoPlayer.isPlaying) {
|
||||
|
||||
+15
-1
@@ -13,6 +13,8 @@ import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
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.MediaRequestData
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
|
||||
sealed interface MediaItem {
|
||||
@@ -27,7 +29,9 @@ sealed interface MediaItem {
|
||||
val timestamp: Long,
|
||||
) : MediaItem
|
||||
|
||||
sealed interface Event : MediaItem
|
||||
sealed interface Event : MediaItem {
|
||||
val validationState: ContentValidationState
|
||||
}
|
||||
|
||||
data class Image(
|
||||
val id: UniqueId,
|
||||
@@ -36,6 +40,7 @@ sealed interface MediaItem {
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val blurHash: String?,
|
||||
override val validationState: ContentValidationState,
|
||||
) : Event {
|
||||
val thumbnailMediaRequestData: MediaRequestData
|
||||
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
|
||||
@@ -48,6 +53,7 @@ sealed interface MediaItem {
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val blurHash: String?,
|
||||
override val validationState: ContentValidationState,
|
||||
) : Event {
|
||||
val thumbnailMediaRequestData: MediaRequestData
|
||||
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
|
||||
@@ -58,6 +64,7 @@ sealed interface MediaItem {
|
||||
val eventId: EventId?,
|
||||
val mediaInfo: MediaInfo,
|
||||
val mediaSource: MediaSource,
|
||||
override val validationState: ContentValidationState,
|
||||
) : Event
|
||||
|
||||
data class Voice(
|
||||
@@ -65,6 +72,7 @@ sealed interface MediaItem {
|
||||
val eventId: EventId?,
|
||||
val mediaInfo: MediaInfo,
|
||||
val mediaSource: MediaSource,
|
||||
override val validationState: ContentValidationState,
|
||||
) : Event
|
||||
|
||||
data class File(
|
||||
@@ -72,6 +80,7 @@ sealed interface MediaItem {
|
||||
val eventId: EventId?,
|
||||
val mediaInfo: MediaInfo,
|
||||
val mediaSource: MediaSource,
|
||||
override val validationState: ContentValidationState,
|
||||
) : Event
|
||||
}
|
||||
|
||||
@@ -134,3 +143,8 @@ fun MediaItem.Event.blurHash(): String? {
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun MediaItem.isMediaValid(): Boolean? = when (this) {
|
||||
is MediaItem.Event -> validationState.getCurrentOverallState() == ContentValidationValue.Valid
|
||||
else -> null
|
||||
}
|
||||
|
||||
+14
-1
@@ -14,6 +14,8 @@ import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
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.Timeline
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.aVoiceMediaInfo
|
||||
@@ -25,6 +27,8 @@ fun aMediaItemImage(
|
||||
eventId: EventId? = null,
|
||||
senderId: UserId? = null,
|
||||
mediaSourceUrl: String = "",
|
||||
validationState: ContentValidationState = NoopContentValidationState(),
|
||||
blurHash: String? = null,
|
||||
): MediaItem.Image {
|
||||
return MediaItem.Image(
|
||||
id = id,
|
||||
@@ -34,7 +38,8 @@ fun aMediaItemImage(
|
||||
),
|
||||
mediaSource = MediaSource(mediaSourceUrl),
|
||||
thumbnailSource = null,
|
||||
blurHash = null,
|
||||
blurHash = blurHash,
|
||||
validationState = validationState,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -42,6 +47,7 @@ fun aMediaItemVideo(
|
||||
id: UniqueId = UniqueId("videoId"),
|
||||
mediaSource: MediaSource = MediaSource(""),
|
||||
duration: String? = "1:23",
|
||||
validationState: ContentValidationState = NoopContentValidationState(),
|
||||
): MediaItem.Video {
|
||||
return MediaItem.Video(
|
||||
id = id,
|
||||
@@ -52,6 +58,7 @@ fun aMediaItemVideo(
|
||||
mediaSource = mediaSource,
|
||||
thumbnailSource = null,
|
||||
blurHash = null,
|
||||
validationState = validationState,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,6 +67,7 @@ fun aMediaItemFile(
|
||||
eventId: EventId? = null,
|
||||
filename: String = "filename",
|
||||
caption: String? = null,
|
||||
validationState: ContentValidationState = NoopContentValidationState(),
|
||||
): MediaItem.File {
|
||||
return MediaItem.File(
|
||||
id = id,
|
||||
@@ -69,6 +77,7 @@ fun aMediaItemFile(
|
||||
caption = caption,
|
||||
),
|
||||
mediaSource = MediaSource(""),
|
||||
validationState = validationState,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -77,6 +86,7 @@ fun aMediaItemAudio(
|
||||
eventId: EventId? = null,
|
||||
filename: String = "filename",
|
||||
caption: String? = null,
|
||||
validationState: ContentValidationState = NoopContentValidationState(),
|
||||
): MediaItem.Audio {
|
||||
return MediaItem.Audio(
|
||||
id = id,
|
||||
@@ -86,6 +96,7 @@ fun aMediaItemAudio(
|
||||
caption = caption,
|
||||
),
|
||||
mediaSource = MediaSource(""),
|
||||
validationState = validationState,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -95,6 +106,7 @@ fun aMediaItemVoice(
|
||||
caption: String? = null,
|
||||
duration: String? = "1:23",
|
||||
waveform: List<Float> = WaveFormSamples.realisticWaveForm,
|
||||
validationState: ContentValidationState = NoopContentValidationState(),
|
||||
): MediaItem.Voice {
|
||||
return MediaItem.Voice(
|
||||
id = id,
|
||||
@@ -106,6 +118,7 @@ fun aMediaItemVoice(
|
||||
waveForm = waveform,
|
||||
),
|
||||
mediaSource = MediaSource(""),
|
||||
validationState = validationState,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -11,6 +11,7 @@ import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.mediaviewer.api.GalleryInfo
|
||||
import io.element.android.libraries.mediaviewer.api.GalleryItemData
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
@@ -37,6 +38,7 @@ class GalleryMediaGalleryDataSource(
|
||||
eventId: EventId?,
|
||||
galleryItems: List<GalleryItemData>,
|
||||
galleryInfo: GalleryInfo,
|
||||
contentValidationState: ContentValidationState,
|
||||
): GalleryMediaGalleryDataSource {
|
||||
val mixedItems = mutableListOf<MediaItem.Event>()
|
||||
galleryItems.forEachIndexed { index, galleryItem ->
|
||||
@@ -64,18 +66,21 @@ class GalleryMediaGalleryDataSource(
|
||||
mediaSource = galleryItem.mediaSource,
|
||||
thumbnailSource = galleryItem.thumbnailSource,
|
||||
blurHash = galleryItem.blurHash,
|
||||
validationState = contentValidationState,
|
||||
)
|
||||
GalleryItemData.Type.Audio -> MediaItem.Audio(
|
||||
id = id,
|
||||
eventId = eventId,
|
||||
mediaInfo = itemMediaInfo,
|
||||
mediaSource = galleryItem.mediaSource,
|
||||
validationState = contentValidationState,
|
||||
)
|
||||
GalleryItemData.Type.File -> MediaItem.File(
|
||||
id = id,
|
||||
eventId = eventId,
|
||||
mediaInfo = itemMediaInfo,
|
||||
mediaSource = galleryItem.mediaSource,
|
||||
validationState = contentValidationState,
|
||||
)
|
||||
GalleryItemData.Type.Image -> MediaItem.Image(
|
||||
id = id,
|
||||
@@ -84,6 +89,7 @@ class GalleryMediaGalleryDataSource(
|
||||
mediaSource = galleryItem.mediaSource,
|
||||
thumbnailSource = galleryItem.thumbnailSource,
|
||||
blurHash = galleryItem.blurHash,
|
||||
validationState = contentValidationState,
|
||||
)
|
||||
}
|
||||
mixedItems.add(mediaItem)
|
||||
|
||||
+27
@@ -17,6 +17,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import io.element.android.features.contentscanner.api.ContentScannerService
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.core.extensions.mapCatchingExceptions
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
@@ -24,6 +25,9 @@ import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||
import io.element.android.libraries.matrix.api.media.MediaFile
|
||||
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 io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint.MediaViewerMode
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
|
||||
@@ -57,6 +61,8 @@ class MediaViewerDataSource(
|
||||
private val localMediaFactory: LocalMediaFactory,
|
||||
private val systemClock: SystemClock,
|
||||
private val pagerKeysHandler: PagerKeysHandler,
|
||||
private val contentScannerService: ContentScannerService,
|
||||
private val contentValidationCache: EventContentValidationCache,
|
||||
) {
|
||||
// List of media files that are currently being loaded
|
||||
private val mediaFiles: ConcurrentHashMap<MediaSource, MediaFile> = ConcurrentHashMap()
|
||||
@@ -71,6 +77,9 @@ class MediaViewerDataSource(
|
||||
private val localMediaStates: MutableMap<String, MutableState<AsyncData<LocalMedia>>> =
|
||||
mutableMapOf()
|
||||
|
||||
private val mediaValidationState: MutableMap<String, ContentValidationState> =
|
||||
mutableMapOf()
|
||||
|
||||
fun setup(coroutineScope: CoroutineScope) {
|
||||
galleryDataSource.start(coroutineScope)
|
||||
}
|
||||
@@ -162,6 +171,9 @@ class MediaViewerDataSource(
|
||||
val localMedia = localMediaStates.getOrPut(sourceUrl) {
|
||||
mutableStateOf(AsyncData.Uninitialized)
|
||||
}
|
||||
val validationState = mediaValidationState.getOrPut(sourceUrl) {
|
||||
mediaItem.eventId()?.let { contentValidationCache[it] } ?: DefaultContentValidationState()
|
||||
}
|
||||
add(
|
||||
MediaViewerPageData.MediaViewerData(
|
||||
eventId = mediaItem.eventId(),
|
||||
@@ -170,6 +182,7 @@ class MediaViewerDataSource(
|
||||
thumbnailSource = mediaItem.thumbnailSource(),
|
||||
downloadedMedia = localMedia,
|
||||
pagerKey = pagerKeysHandler.getKey(mediaItem),
|
||||
validationState = validationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -229,6 +242,20 @@ class MediaViewerDataSource(
|
||||
}
|
||||
}
|
||||
|
||||
fun validateMedia(
|
||||
eventId: EventId,
|
||||
mediaSource: MediaSource,
|
||||
) {
|
||||
val validationState = mediaValidationState[mediaSource.safeUrl] ?: return
|
||||
val currentState = validationState.getCurrentMediaState(mediaSource.safeUrl)
|
||||
|
||||
if (currentState.isLoading() || currentState.isValid()) {
|
||||
return
|
||||
}
|
||||
|
||||
contentScannerService.scan(eventId, listOf(mediaSource), validationState)
|
||||
}
|
||||
|
||||
fun cancelLoadingMedia(data: MediaViewerPageData.MediaViewerData) {
|
||||
if (localMediaStates[data.mediaSource.safeUrl]?.value?.isLoading() == true) {
|
||||
Timber.d("cancelLoadingMedia for ${data.eventId}")
|
||||
|
||||
+2
@@ -9,6 +9,7 @@
|
||||
package io.element.android.libraries.mediaviewer.impl.viewer
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
|
||||
sealed interface MediaViewerEvent {
|
||||
@@ -30,4 +31,5 @@ sealed interface MediaViewerEvent {
|
||||
data class OnNavigateTo(val index: Int) : MediaViewerEvent
|
||||
data class LoadMore(val direction: Timeline.PaginationDirection) : MediaViewerEvent
|
||||
data class CancelLoadingMedia(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvent
|
||||
data class ValidateMedia(val eventId: EventId, val mediaSource: MediaSource) : MediaViewerEvent
|
||||
}
|
||||
|
||||
+11
-3
@@ -22,6 +22,7 @@ import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.annotations.ContributesNode
|
||||
import io.element.android.compound.colors.SemanticColorsLightDark
|
||||
import io.element.android.compound.theme.ForcedDarkElementTheme
|
||||
import io.element.android.features.contentscanner.api.ContentScannerService
|
||||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.features.viewfolder.api.TextFileViewer
|
||||
import io.element.android.libraries.architecture.callback
|
||||
@@ -33,6 +34,8 @@ import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
|
||||
import io.element.android.libraries.mediaviewer.impl.datasource.FocusedTimelineMediaGalleryDataSourceFactory
|
||||
@@ -57,6 +60,8 @@ class MediaViewerNode(
|
||||
private val audioFocus: AudioFocus,
|
||||
private val sessionId: SessionId,
|
||||
private val enterpriseService: EnterpriseService,
|
||||
private val contentScannerService: ContentScannerService,
|
||||
private val contentValidationCache: EventContentValidationCache,
|
||||
) : Node(buildContext, plugins = plugins),
|
||||
MediaViewerNavigator {
|
||||
private val callback: MediaViewerEntryPoint.Callback = callback()
|
||||
@@ -76,12 +81,13 @@ class MediaViewerNode(
|
||||
|
||||
private val mediaGallerySource = when (inputs) {
|
||||
is MediaViewerEntryPoint.Params.Avatar ->
|
||||
SingleMediaGalleryDataSource.createFrom(inputs)
|
||||
SingleMediaGalleryDataSource.createFrom(inputs, contentValidationCache)
|
||||
is MediaViewerEntryPoint.Params.EventGallery ->
|
||||
GalleryMediaGalleryDataSource.createFrom(
|
||||
eventId = inputs.eventId,
|
||||
galleryItems = inputs.galleryItems,
|
||||
galleryInfo = inputs.galleryInfo,
|
||||
contentValidationState = inputs.eventId?.let { contentValidationCache[it] } ?: NoopContentValidationState(),
|
||||
)
|
||||
is MediaViewerEntryPoint.Params.RoomMedia -> {
|
||||
val eventId = inputs.eventId
|
||||
@@ -103,7 +109,7 @@ class MediaViewerNode(
|
||||
} else {
|
||||
focusedTimelineMediaGalleryDataSourceFactory.createFor(
|
||||
eventId = eventId,
|
||||
mediaItem = inputs.toMediaItem(),
|
||||
mediaItem = inputs.toMediaItem(contentValidationCache[eventId]),
|
||||
onlyPinnedEvents = false,
|
||||
)
|
||||
}
|
||||
@@ -111,7 +117,7 @@ class MediaViewerNode(
|
||||
Timeline.Mode.PinnedEvents -> {
|
||||
focusedTimelineMediaGalleryDataSourceFactory.createFor(
|
||||
eventId = eventId,
|
||||
mediaItem = inputs.toMediaItem(),
|
||||
mediaItem = inputs.toMediaItem(contentValidationCache[eventId]),
|
||||
onlyPinnedEvents = true,
|
||||
)
|
||||
}
|
||||
@@ -142,6 +148,8 @@ class MediaViewerNode(
|
||||
localMediaFactory = localMediaFactory,
|
||||
systemClock = systemClock,
|
||||
pagerKeysHandler = pagerKeysHandler,
|
||||
contentScannerService = contentScannerService,
|
||||
contentValidationCache = contentValidationCache,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
+5
-1
@@ -42,7 +42,9 @@ import io.element.android.libraries.mediaviewer.impl.model.MediaPermissions
|
||||
import io.element.android.libraries.mediaviewer.impl.model.mediaPermissions
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import io.element.android.libraries.androidutils.R as UtilsR
|
||||
@@ -70,6 +72,7 @@ class MediaViewerPresenter(
|
||||
private val eventId = inputs.eventId()
|
||||
private val mediaSource = inputs.mediaSource()
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Composable
|
||||
override fun present(): MediaViewerState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
@@ -93,7 +96,7 @@ class MediaViewerPresenter(
|
||||
// Restore index based on the eventId after the initial items have been loaded
|
||||
currentIndex.intValue = dataSource.findEventIndex(eventId, mediaSource) ?: 0
|
||||
}
|
||||
value = new
|
||||
value = new.toImmutableList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +194,7 @@ class MediaViewerPresenter(
|
||||
is MediaViewerEvent.LoadMore -> coroutineScope.launch {
|
||||
dataSource.loadMore(event.direction)
|
||||
}
|
||||
is MediaViewerEvent.ValidateMedia -> dataSource.validateMedia(event.eventId, event.mediaSource)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -15,6 +15,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
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 io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
|
||||
@@ -51,6 +52,7 @@ sealed interface MediaViewerPageData {
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val downloadedMedia: State<AsyncData<LocalMedia>>,
|
||||
val validationState: ContentValidationState,
|
||||
override val pagerKey: Long,
|
||||
) : MediaViewerPageData
|
||||
}
|
||||
|
||||
+33
@@ -17,6 +17,9 @@ import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
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 io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.aTxtMediaInfo
|
||||
@@ -275,6 +278,34 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
|
||||
)
|
||||
)
|
||||
},
|
||||
anImageMediaInfo(
|
||||
senderName = "Frank",
|
||||
dateSent = "26 NOV, 2024",
|
||||
).let {
|
||||
aMediaViewerState(
|
||||
listOf(
|
||||
aMediaViewerPageData(
|
||||
downloadedMedia = AsyncData.Uninitialized,
|
||||
validationState = NoopContentValidationState(ContentValidationValue.Loading),
|
||||
mediaInfo = it,
|
||||
)
|
||||
)
|
||||
)
|
||||
},
|
||||
anImageMediaInfo(
|
||||
senderName = "Frank",
|
||||
dateSent = "26 NOV, 2024",
|
||||
).let {
|
||||
aMediaViewerState(
|
||||
listOf(
|
||||
aMediaViewerPageData(
|
||||
downloadedMedia = AsyncData.Uninitialized,
|
||||
validationState = NoopContentValidationState(ContentValidationValue.Invalid),
|
||||
mediaInfo = it,
|
||||
)
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -293,6 +324,7 @@ fun aMediaViewerPageData(
|
||||
downloadedMedia: AsyncData<LocalMedia> = AsyncData.Uninitialized,
|
||||
mediaInfo: MediaInfo = anImageMediaInfo(),
|
||||
mediaSource: MediaSource = MediaSource(""),
|
||||
validationState: ContentValidationState = NoopContentValidationState(),
|
||||
): MediaViewerPageData.MediaViewerData = MediaViewerPageData.MediaViewerData(
|
||||
eventId = null,
|
||||
mediaInfo = mediaInfo,
|
||||
@@ -300,6 +332,7 @@ fun aMediaViewerPageData(
|
||||
thumbnailSource = null,
|
||||
downloadedMedia = mutableStateOf(downloadedMedia),
|
||||
pagerKey = 0L,
|
||||
validationState = validationState,
|
||||
)
|
||||
|
||||
fun aMediaViewerState(
|
||||
|
||||
+84
-33
@@ -12,11 +12,14 @@ package io.element.android.libraries.mediaviewer.impl.viewer
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
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.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -73,7 +76,10 @@ import io.element.android.libraries.androidutils.text.safeLinkify
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.audio.api.AudioFocus
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||
import io.element.android.libraries.designsystem.components.BigIcon
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncFailure
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncLoading
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
|
||||
@@ -176,7 +182,7 @@ fun MediaViewerView(
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) {
|
||||
) { padding ->
|
||||
val pagerState = rememberPagerState(state.currentIndex, 0f) {
|
||||
state.listData.size
|
||||
}
|
||||
@@ -211,17 +217,33 @@ fun MediaViewerView(
|
||||
)
|
||||
}
|
||||
is MediaViewerPageData.MediaViewerData -> {
|
||||
val eventId = dataForPage.eventId
|
||||
|
||||
val isMediaValid = dataForPage.validationState.getCurrentMediaState(dataForPage.mediaSource.safeUrl).isValid()
|
||||
|
||||
// Check if the media to display is valid or dangerous
|
||||
LaunchedEffect(eventId) {
|
||||
if (eventId != null) {
|
||||
state.eventSink(MediaViewerEvent.ValidateMedia(eventId, dataForPage.mediaSource))
|
||||
}
|
||||
}
|
||||
|
||||
var bottomPaddingInPixels by remember { mutableIntStateOf(defaultBottomPaddingInPixels) }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.onVisibilityChanged(minDurationMs = 200L) { isVisible ->
|
||||
val loadMediaOnVisibilityChangedModifier = remember(isMediaValid) {
|
||||
if (isMediaValid == true) {
|
||||
Modifier.onVisibilityChanged(minDurationMs = 200L) { isVisible ->
|
||||
if (isVisible) {
|
||||
state.eventSink(MediaViewerEvent.LoadMedia(dataForPage))
|
||||
} else {
|
||||
state.eventSink(MediaViewerEvent.CancelLoadingMedia(dataForPage))
|
||||
}
|
||||
}
|
||||
.fillMaxSize()
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = loadMediaOnVisibilityChangedModifier.fillMaxSize()
|
||||
) {
|
||||
val isDisplayed = remember(pagerState.settledPage) {
|
||||
// This 'item provider' lambda will be called when the data source changes with an outdated `settlePage` value
|
||||
@@ -232,6 +254,7 @@ fun MediaViewerView(
|
||||
MediaViewerPage(
|
||||
isDisplayed = isDisplayed,
|
||||
showOverlay = showOverlay,
|
||||
containerPadding = padding,
|
||||
bottomPaddingInPixels = (bottomPaddingInPixels - navigationBarPadding).coerceAtLeast(0),
|
||||
data = dataForPage,
|
||||
textFileViewer = textFileViewer,
|
||||
@@ -333,6 +356,7 @@ private fun MediaViewerPage(
|
||||
isDisplayed: Boolean,
|
||||
showOverlay: Boolean,
|
||||
bottomPaddingInPixels: Int,
|
||||
containerPadding: PaddingValues,
|
||||
data: MediaViewerPageData.MediaViewerData,
|
||||
textFileViewer: TextFileViewer,
|
||||
isUserSelected: Boolean,
|
||||
@@ -365,6 +389,25 @@ private fun MediaViewerPage(
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = data.validationState.getCurrentMediaState(data.mediaSource.safeUrl).isLoading(),
|
||||
modifier = Modifier.padding(top = containerPadding.calculateTopPadding()).align(Alignment.TopCenter),
|
||||
enter = fadeIn(spring(stiffness = 500F)),
|
||||
exit = fadeOut(spring(stiffness = 500F)),
|
||||
) {
|
||||
AsyncIndicator.Loading(
|
||||
text = stringResource(CommonStrings.content_scanner_scanning),
|
||||
)
|
||||
}
|
||||
|
||||
if (showProgress) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
val zoomableState = rememberZoomableState(
|
||||
zoomSpec = ZoomSpec(maxZoomFactor = 4f, overzoomEffect = OverzoomEffect.NoLimits)
|
||||
@@ -380,29 +423,44 @@ private fun MediaViewerPage(
|
||||
}
|
||||
}
|
||||
|
||||
LocalMediaView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
isDisplayed = isDisplayed,
|
||||
bottomPaddingInPixels = bottomPaddingInPixels,
|
||||
localMediaViewState = localMediaViewState,
|
||||
localMedia = downloadedMedia.dataOrNull(),
|
||||
mediaInfo = data.mediaInfo,
|
||||
textFileViewer = textFileViewer,
|
||||
onClick = {
|
||||
if (playableState is PlayableState.NotPlayable) {
|
||||
currentOnShowOverlayChange(!currentShowOverlay)
|
||||
}
|
||||
},
|
||||
onOpenWith = onOpenWith,
|
||||
isUserSelected = isUserSelected,
|
||||
audioFocus = audioFocus,
|
||||
forPreview = false,
|
||||
)
|
||||
if (showThumbnail) {
|
||||
ThumbnailView(
|
||||
val isMediaValid = data.validationState.getCurrentMediaState(data.mediaSource.safeUrl).isValid()
|
||||
|
||||
if (isMediaValid == false) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
IconTitleSubtitleMolecule(
|
||||
iconStyle = BigIcon.Style.AlertSolid,
|
||||
title = "The file is not safe",
|
||||
subTitle = "Preview and download have been disabled.",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LocalMediaView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
isDisplayed = isDisplayed,
|
||||
bottomPaddingInPixels = bottomPaddingInPixels,
|
||||
localMediaViewState = localMediaViewState,
|
||||
localMedia = downloadedMedia.dataOrNull(),
|
||||
mediaInfo = data.mediaInfo,
|
||||
thumbnailSource = data.thumbnailSource,
|
||||
textFileViewer = textFileViewer,
|
||||
onClick = {
|
||||
if (playableState is PlayableState.NotPlayable) {
|
||||
currentOnShowOverlayChange(!currentShowOverlay)
|
||||
}
|
||||
},
|
||||
onOpenWith = onOpenWith,
|
||||
isUserSelected = isUserSelected,
|
||||
audioFocus = audioFocus,
|
||||
forPreview = false,
|
||||
)
|
||||
if (showThumbnail) {
|
||||
ThumbnailView(
|
||||
mediaInfo = data.mediaInfo,
|
||||
thumbnailSource = data.thumbnailSource,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showError) {
|
||||
ErrorView(
|
||||
@@ -412,13 +470,6 @@ private fun MediaViewerPage(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showProgress) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+21
-5
@@ -16,6 +16,9 @@ import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||
import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource
|
||||
@@ -27,6 +30,7 @@ import kotlinx.coroutines.flow.flowOf
|
||||
|
||||
class SingleMediaGalleryDataSource(
|
||||
private val data: GroupedMediaItems,
|
||||
private val contentValidationCache: EventContentValidationCache,
|
||||
) : MediaGalleryDataSource {
|
||||
override fun start(coroutineScope: CoroutineScope) = Unit
|
||||
override fun groupedMediaItemsFlow() = flowOf(AsyncData.Success(data))
|
||||
@@ -38,7 +42,10 @@ class SingleMediaGalleryDataSource(
|
||||
override suspend fun deleteItem(eventId: EventId) = Unit
|
||||
|
||||
companion object {
|
||||
fun createFrom(params: MediaViewerEntryPoint.Params.Avatar) = SingleMediaGalleryDataSource(
|
||||
fun createFrom(
|
||||
params: MediaViewerEntryPoint.Params.Avatar,
|
||||
contentValidationCache: EventContentValidationCache,
|
||||
) = SingleMediaGalleryDataSource(
|
||||
data = GroupedMediaItems(
|
||||
// Always use imageAndVideoItems, in Single mode, this is the data that will be used
|
||||
imageAndVideoItems = persistentListOf(
|
||||
@@ -64,15 +71,19 @@ class SingleMediaGalleryDataSource(
|
||||
mediaSource = params.mediaSource,
|
||||
thumbnailSource = params.thumbnailSource,
|
||||
blurHash = params.blurHash,
|
||||
validationState = DefaultContentValidationState(),
|
||||
)
|
||||
),
|
||||
fileItems = persistentListOf(),
|
||||
)
|
||||
),
|
||||
contentValidationCache = contentValidationCache,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
|
||||
fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem(
|
||||
contentValidationState: ContentValidationState,
|
||||
) = when {
|
||||
mediaInfo.mimeType.isMimeTypeImage() -> {
|
||||
MediaItem.Image(
|
||||
id = UniqueId("dummy"),
|
||||
@@ -80,7 +91,8 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
|
||||
mediaInfo = mediaInfo,
|
||||
mediaSource = mediaSource,
|
||||
thumbnailSource = thumbnailSource,
|
||||
blurHash = blurHash,
|
||||
blurHash = null,
|
||||
validationState = contentValidationState,
|
||||
)
|
||||
}
|
||||
mediaInfo.mimeType.isMimeTypeVideo() -> {
|
||||
@@ -90,7 +102,8 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
|
||||
mediaInfo = mediaInfo,
|
||||
mediaSource = mediaSource,
|
||||
thumbnailSource = thumbnailSource,
|
||||
blurHash = blurHash,
|
||||
blurHash = null,
|
||||
validationState = contentValidationState,
|
||||
)
|
||||
}
|
||||
mediaInfo.mimeType.isMimeTypeAudio() -> {
|
||||
@@ -100,6 +113,7 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
|
||||
eventId = eventId,
|
||||
mediaInfo = mediaInfo,
|
||||
mediaSource = mediaSource,
|
||||
validationState = contentValidationState,
|
||||
)
|
||||
} else {
|
||||
MediaItem.Voice(
|
||||
@@ -107,6 +121,7 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
|
||||
eventId = eventId,
|
||||
mediaInfo = mediaInfo,
|
||||
mediaSource = mediaSource,
|
||||
validationState = contentValidationState,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -116,6 +131,7 @@ fun MediaViewerEntryPoint.Params.RoomMedia.toMediaItem() = when {
|
||||
eventId = eventId,
|
||||
mediaInfo = mediaInfo,
|
||||
mediaSource = mediaSource,
|
||||
validationState = contentValidationState,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -12,11 +12,13 @@ import android.net.Uri
|
||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.contentscanner.api.ContentScannerService
|
||||
import io.element.android.features.enterprise.test.FakeEnterpriseService
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache
|
||||
import io.element.android.libraries.mediaplayer.test.FakeAudioFocus
|
||||
import io.element.android.libraries.mediaviewer.api.AvatarInfo
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||
@@ -71,6 +73,8 @@ class DefaultMediaViewerEntryPointTest {
|
||||
audioFocus = FakeAudioFocus(),
|
||||
sessionId = A_SESSION_ID,
|
||||
enterpriseService = FakeEnterpriseService(),
|
||||
contentValidationCache = NoopEventContentValidationCache(),
|
||||
contentScannerService = ContentScannerService { _, _, _ -> },
|
||||
)
|
||||
}
|
||||
val callback = object : MediaViewerEntryPoint.Callback {
|
||||
@@ -118,6 +122,8 @@ class DefaultMediaViewerEntryPointTest {
|
||||
audioFocus = FakeAudioFocus(),
|
||||
sessionId = A_SESSION_ID,
|
||||
enterpriseService = FakeEnterpriseService(),
|
||||
contentValidationCache = NoopEventContentValidationCache(),
|
||||
contentScannerService = ContentScannerService { _, _, _ -> },
|
||||
)
|
||||
}
|
||||
val callback = object : MediaViewerEntryPoint.Callback {
|
||||
|
||||
+14
@@ -52,6 +52,8 @@ import io.element.android.libraries.matrix.test.timeline.aProfileChangeMessageCo
|
||||
import io.element.android.libraries.matrix.test.timeline.aStickerContent
|
||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||
import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
||||
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
|
||||
@@ -174,6 +176,7 @@ class EventItemFactoryTest {
|
||||
duration = null,
|
||||
),
|
||||
mediaSource = MediaSource(""),
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -227,6 +230,7 @@ class EventItemFactoryTest {
|
||||
mediaSource = MediaSource(""),
|
||||
thumbnailSource = null,
|
||||
blurHash = null,
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -274,6 +278,7 @@ class EventItemFactoryTest {
|
||||
duration = null,
|
||||
),
|
||||
mediaSource = MediaSource(""),
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -328,6 +333,7 @@ class EventItemFactoryTest {
|
||||
mediaSource = MediaSource(""),
|
||||
thumbnailSource = null,
|
||||
blurHash = null,
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -379,6 +385,7 @@ class EventItemFactoryTest {
|
||||
duration = "7:36",
|
||||
),
|
||||
mediaSource = MediaSource(""),
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -440,6 +447,7 @@ class EventItemFactoryTest {
|
||||
mediaSource = MediaSource("image_url"),
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
blurHash = null,
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -502,6 +510,7 @@ class EventItemFactoryTest {
|
||||
mediaSource = MediaSource("video_url"),
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
blurHash = null,
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -557,6 +566,7 @@ class EventItemFactoryTest {
|
||||
duration = null,
|
||||
),
|
||||
mediaSource = MediaSource("audio_url"),
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -613,6 +623,7 @@ class EventItemFactoryTest {
|
||||
duration = null,
|
||||
),
|
||||
mediaSource = MediaSource("file_url"),
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -749,13 +760,16 @@ class EventItemFactoryTest {
|
||||
mediaSource = MediaSource(""),
|
||||
thumbnailSource = null,
|
||||
blurHash = null,
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val noopValidationState = NoopContentValidationState()
|
||||
private fun createEventItemFactory() = EventItemFactory(
|
||||
fileSizeFormatter = FakeFileSizeFormatter(),
|
||||
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
|
||||
dateFormatter = FakeDateFormatter(),
|
||||
contentValidationCache = NoopEventContentValidationCache(noopValidationState),
|
||||
)
|
||||
|
||||
+7
-1
@@ -34,6 +34,8 @@ import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.matrix.test.timeline.aMessageContent
|
||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
|
||||
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
||||
@@ -250,6 +252,7 @@ class TimelineMediaGalleryDataSourceTest {
|
||||
mediaSource = MediaSource("url"),
|
||||
thumbnailSource = MediaSource("url_thumbnail"),
|
||||
blurHash = A_BLUR_HASH,
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
),
|
||||
fileItems = persistentListOf()
|
||||
@@ -260,6 +263,8 @@ class TimelineMediaGalleryDataSourceTest {
|
||||
}
|
||||
}
|
||||
|
||||
private val noopValidationState = NoopContentValidationState()
|
||||
|
||||
internal fun TestScope.createTimelineMediaGalleryDataSource(
|
||||
room: JoinedRoom = FakeJoinedRoom(
|
||||
liveTimeline = FakeTimeline(),
|
||||
@@ -282,5 +287,6 @@ fun TestScope.createTimelineMediaItemsFactory() = TimelineMediaItemsFactory(
|
||||
fileSizeFormatter = FakeFileSizeFormatter(),
|
||||
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
|
||||
dateFormatter = FakeDateFormatter(),
|
||||
),
|
||||
contentValidationCache = NoopEventContentValidationCache(noopValidationState),
|
||||
)
|
||||
)
|
||||
|
||||
+1
@@ -534,6 +534,7 @@ class MediaGalleryPresenterTest {
|
||||
mediaLoader = matrixMediaLoader,
|
||||
localMediaActions = localMediaActions,
|
||||
snackbarDispatcher = snackbarDispatcher,
|
||||
contentScannerService = { _, _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
|
||||
import io.element.android.libraries.mediaviewer.api.GalleryInfo
|
||||
import io.element.android.libraries.mediaviewer.api.GalleryItemData
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
@@ -95,6 +96,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
)
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
contentValidationState = noopValidationState,
|
||||
)
|
||||
val data = (result.getLastData() as AsyncData.Success).data
|
||||
assertThat(data.fileItems).isEmpty()
|
||||
@@ -106,6 +108,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
mediaSource = MediaSource("image_url"),
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
blurHash = null,
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -125,6 +128,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
)
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
contentValidationState = noopValidationState,
|
||||
)
|
||||
val data = (result.getLastData() as AsyncData.Success).data
|
||||
assertThat(data.fileItems).isEmpty()
|
||||
@@ -136,6 +140,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
mediaSource = MediaSource("video_url"),
|
||||
thumbnailSource = MediaSource("thumbnail_url"),
|
||||
blurHash = null,
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -155,6 +160,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
)
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
contentValidationState = noopValidationState,
|
||||
)
|
||||
val data = (result.getLastData() as AsyncData.Success).data
|
||||
assertThat(data.fileItems).isEmpty()
|
||||
@@ -164,6 +170,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
eventId = AN_EVENT_ID,
|
||||
mediaInfo = expectedMediaInfo("audio.mp3", MimeTypes.Mp3),
|
||||
mediaSource = MediaSource("audio_url"),
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -183,6 +190,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
)
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
contentValidationState = noopValidationState,
|
||||
)
|
||||
val data = (result.getLastData() as AsyncData.Success).data
|
||||
assertThat(data.fileItems).isEmpty()
|
||||
@@ -192,6 +200,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
eventId = AN_EVENT_ID,
|
||||
mediaInfo = expectedMediaInfo("document.pdf", MimeTypes.Pdf),
|
||||
mediaSource = MediaSource("file_url"),
|
||||
validationState = noopValidationState,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -211,6 +220,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
)
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
contentValidationState = noopValidationState,
|
||||
)
|
||||
val data = (result.getLastData() as AsyncData.Success).data
|
||||
val item = data.imageAndVideoItems.single() as MediaItem.Image
|
||||
@@ -249,6 +259,7 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
),
|
||||
),
|
||||
galleryInfo = aGalleryInfo(),
|
||||
contentValidationState = noopValidationState,
|
||||
)
|
||||
val data = (result.getLastData() as AsyncData.Success).data
|
||||
assertThat(data.fileItems).isEmpty()
|
||||
@@ -258,6 +269,8 @@ class GalleryMediaGalleryDataSourceTest {
|
||||
assertThat((data.imageAndVideoItems[2] as MediaItem.Video).id).isEqualTo(UniqueId("${AN_EVENT_ID.value}_2"))
|
||||
}
|
||||
|
||||
private val noopValidationState = NoopContentValidationState()
|
||||
|
||||
private fun aGalleryInfo() = GalleryInfo(
|
||||
caption = "A caption",
|
||||
formattedCaption = null,
|
||||
|
||||
+3
@@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||
import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint.MediaViewerMode
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
|
||||
import io.element.android.libraries.mediaviewer.impl.datasource.FakeMediaGalleryDataSource
|
||||
@@ -290,5 +291,7 @@ class MediaViewerDataSourceTest {
|
||||
localMediaFactory = localMediaFactory,
|
||||
systemClock = FakeSystemClock(),
|
||||
pagerKeysHandler = PagerKeysHandler(),
|
||||
contentScannerService = { _, _, _ -> },
|
||||
contentValidationCache = NoopEventContentValidationCache(),
|
||||
)
|
||||
}
|
||||
|
||||
+3
@@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache
|
||||
import io.element.android.libraries.mediaviewer.api.AvatarInfo
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
|
||||
@@ -969,6 +970,8 @@ internal fun TestScope.createMediaViewerPresenter(
|
||||
localMediaFactory = localMediaFactory,
|
||||
systemClock = FakeSystemClock(),
|
||||
pagerKeysHandler = PagerKeysHandler(),
|
||||
contentScannerService = { _, _, _ -> },
|
||||
contentValidationCache = NoopEventContentValidationCache(),
|
||||
),
|
||||
room = room,
|
||||
localMediaActions = localMediaActions,
|
||||
|
||||
+14
-6
@@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.media.aMediaSource
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
|
||||
@@ -36,22 +37,24 @@ class SingleMediaGalleryDataSourceTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
private val contentValidationCache = NoopEventContentValidationCache()
|
||||
|
||||
@Test
|
||||
fun `function start is no op`() = runTest {
|
||||
val sut = SingleMediaGalleryDataSource(aGroupedMediaItems())
|
||||
val sut = SingleMediaGalleryDataSource(aGroupedMediaItems(), contentValidationCache)
|
||||
sut.start(backgroundScope)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `function loadMore is no op`() = runTest {
|
||||
val sut = SingleMediaGalleryDataSource(aGroupedMediaItems())
|
||||
val sut = SingleMediaGalleryDataSource(aGroupedMediaItems(), contentValidationCache)
|
||||
sut.loadMore(Timeline.PaginationDirection.BACKWARDS)
|
||||
sut.loadMore(Timeline.PaginationDirection.FORWARDS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `function deleteItem is no op`() = runTest {
|
||||
val sut = SingleMediaGalleryDataSource(aGroupedMediaItems())
|
||||
val sut = SingleMediaGalleryDataSource(aGroupedMediaItems(), contentValidationCache)
|
||||
sut.deleteItem(AN_EVENT_ID)
|
||||
}
|
||||
|
||||
@@ -61,7 +64,7 @@ class SingleMediaGalleryDataSourceTest {
|
||||
imageAndVideoItems = listOf(aMediaItemImage()),
|
||||
fileItems = listOf(aMediaItemFile()),
|
||||
)
|
||||
val sut = SingleMediaGalleryDataSource(data)
|
||||
val sut = SingleMediaGalleryDataSource(data, contentValidationCache)
|
||||
assertThat(sut.getLastData()).isEqualTo(AsyncData.Success(data))
|
||||
}
|
||||
|
||||
@@ -71,7 +74,7 @@ class SingleMediaGalleryDataSourceTest {
|
||||
imageAndVideoItems = listOf(aMediaItemImage()),
|
||||
fileItems = listOf(aMediaItemFile()),
|
||||
)
|
||||
val sut = SingleMediaGalleryDataSource(data)
|
||||
val sut = SingleMediaGalleryDataSource(data, contentValidationCache)
|
||||
sut.groupedMediaItemsFlow().test {
|
||||
assertThat(awaitItem()).isEqualTo(AsyncData.Success(data))
|
||||
awaitComplete()
|
||||
@@ -90,6 +93,7 @@ class SingleMediaGalleryDataSourceTest {
|
||||
mediaSource = params.mediaSource,
|
||||
thumbnailSource = params.thumbnailSource,
|
||||
blurHash = null,
|
||||
validationState = contentValidationCache[AN_EVENT_ID],
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -107,6 +111,7 @@ class SingleMediaGalleryDataSourceTest {
|
||||
mediaSource = params.mediaSource,
|
||||
thumbnailSource = params.thumbnailSource,
|
||||
blurHash = null,
|
||||
validationState = contentValidationCache[AN_EVENT_ID],
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -122,6 +127,7 @@ class SingleMediaGalleryDataSourceTest {
|
||||
eventId = params.eventId,
|
||||
mediaInfo = params.mediaInfo,
|
||||
mediaSource = params.mediaSource,
|
||||
validationState = contentValidationCache[AN_EVENT_ID],
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -140,6 +146,7 @@ class SingleMediaGalleryDataSourceTest {
|
||||
eventId = params.eventId,
|
||||
mediaInfo = params.mediaInfo,
|
||||
mediaSource = params.mediaSource,
|
||||
validationState = contentValidationCache[AN_EVENT_ID],
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -155,6 +162,7 @@ class SingleMediaGalleryDataSourceTest {
|
||||
eventId = params.eventId,
|
||||
mediaInfo = params.mediaInfo,
|
||||
mediaSource = params.mediaSource,
|
||||
validationState = contentValidationCache[AN_EVENT_ID],
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -165,7 +173,7 @@ class SingleMediaGalleryDataSourceTest {
|
||||
expectedResult: (MediaViewerEntryPoint.Params.RoomMedia) -> MediaItem,
|
||||
) {
|
||||
val params = aMediaViewerEntryPointParams(mediaInfo)
|
||||
val result = params.toMediaItem()
|
||||
val result = params.toMediaItem(contentValidationCache[AN_EVENT_ID],)
|
||||
assertThat(result).isEqualTo(expectedResult(params))
|
||||
}
|
||||
|
||||
|
||||
+12
@@ -23,14 +23,17 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
internal fun FormattingOption(
|
||||
@@ -52,6 +55,8 @@ internal fun FormattingOption(
|
||||
FormattingOptionState.Default -> ElementTheme.colors.iconSecondary
|
||||
FormattingOptionState.Disabled -> ElementTheme.colors.iconDisabled
|
||||
}
|
||||
val activeStateDescription = stringResource(CommonStrings.a11y_active)
|
||||
val inactiveStateDescription = stringResource(CommonStrings.a11y_inactive)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clickable(
|
||||
@@ -77,6 +82,13 @@ internal fun FormattingOption(
|
||||
)
|
||||
.clearAndSetSemantics {
|
||||
this.contentDescription = contentDescription
|
||||
if (toggleable) {
|
||||
this.stateDescription = if (state == FormattingOptionState.Selected) {
|
||||
activeStateDescription
|
||||
} else {
|
||||
inactiveStateDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
|
||||
@@ -10,4 +10,6 @@
|
||||
<string name="a11y_jump_to_unread_messages">"Jump to first unread message"</string>
|
||||
<string name="screen_local_network_opt_in_title">Allow access to local network</string>
|
||||
<string name="screen_local_network_opt_in_subtitle">Your homeserver is on your local network. To connect, Element needs permission to reach other devices on this network.</string>
|
||||
<string name="a11y_active">Active</string>
|
||||
<string name="a11y_inactive">Inactive</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user