Merge pull request #6643 from element-hq/feature/bma/updateMediaViewer

Update media viewer UI
This commit is contained in:
Benoit Marty 2026-04-24 17:48:38 +02:00 committed by GitHub
commit 3c51732d35
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
78 changed files with 722 additions and 456 deletions

View file

@ -65,7 +65,7 @@ enum class AvatarSize(val dp: Dp) {
KnockRequestItem(52.dp),
KnockRequestBanner(32.dp),
MediaSender(32.dp),
MediaSender(52.dp),
DmCreationConfirmation(64.dp),

View file

@ -15,16 +15,16 @@ import io.element.android.libraries.mediaviewer.api.MediaInfo
sealed interface MediaBottomSheetState {
data object Hidden : MediaBottomSheetState
data class MediaDeleteConfirmationState(
val eventId: EventId,
val mediaInfo: MediaInfo,
val thumbnailSource: MediaSource?,
) : MediaBottomSheetState
data class MediaDetailsBottomSheetState(
data class Details(
val eventId: EventId?,
val canDelete: Boolean,
val mediaInfo: MediaInfo,
val thumbnailSource: MediaSource?,
) : MediaBottomSheetState
data class DeleteConfirmation(
val eventId: EventId,
val mediaInfo: MediaInfo,
val thumbnailSource: MediaSource?,
) : MediaBottomSheetState
}

View file

@ -0,0 +1,36 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.details
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
open class MediaBottomSheetStateDeleteConfirmationProvider : PreviewParameterProvider<MediaBottomSheetState.DeleteConfirmation> {
override val values: Sequence<MediaBottomSheetState.DeleteConfirmation>
get() = sequenceOf(
aMediaBottomSheetStateDeleteConfirmation(),
aMediaBottomSheetStateDeleteConfirmation(
thumbnailSource = MediaSource("url_thumbnail")
),
)
}
fun aMediaBottomSheetStateDeleteConfirmation(
mediaInfo: MediaInfo = anImageMediaInfo(
senderName = "Alice",
),
thumbnailSource: MediaSource? = null,
) = MediaBottomSheetState.DeleteConfirmation(
eventId = EventId("\$eventId"),
mediaInfo = mediaInfo,
thumbnailSource = thumbnailSource,
)

View file

@ -0,0 +1,46 @@
/*
* 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.details
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
open class MediaBottomSheetStateDetailsProvider : PreviewParameterProvider<MediaBottomSheetState.Details> {
override val values: Sequence<MediaBottomSheetState.Details>
get() = sequenceOf(
aMediaBottomSheetStateDetails(),
aMediaBottomSheetStateDetails(
canDelete = false,
),
aMediaBottomSheetStateDetails(
mediaInfo = anApkMediaInfo(
dateSentFull = "December 6, 2024 at 12:59",
),
),
aMediaBottomSheetStateDetails(
eventId = null,
),
)
}
fun aMediaBottomSheetStateDetails(
eventId: EventId? = EventId($$"$eventId"),
canDelete: Boolean = true,
mediaInfo: MediaInfo = anImageMediaInfo(
senderName = "Alice",
dateSentFull = "December 6, 2024 at 12:59",
),
) = MediaBottomSheetState.Details(
eventId = eventId,
canDelete = canDelete,
mediaInfo = mediaInfo,
thumbnailSource = null,
)

View file

@ -30,6 +30,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import io.element.android.compound.theme.ElementTheme
@ -45,11 +46,12 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.mediaviewer.impl.R
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.strings.Strings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MediaDeleteConfirmationBottomSheet(
state: MediaBottomSheetState.MediaDeleteConfirmationState,
state: MediaBottomSheetState.DeleteConfirmation,
onDelete: (EventId) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
@ -105,7 +107,7 @@ fun MediaDeleteConfirmationBottomSheet(
@Composable
private fun MediaRow(
state: MediaBottomSheetState.MediaDeleteConfirmationState,
state: MediaBottomSheetState.DeleteConfirmation,
modifier: Modifier = Modifier,
) {
Row(
@ -148,7 +150,7 @@ private fun MediaRow(
)
// Info
Text(
text = state.mediaInfo.mimeType + " - " + state.mediaInfo.formattedFileSize,
text = state.mediaInfo.mimeType + Strings.NICE_SEPARATOR + state.mediaInfo.formattedFileSize,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@ -160,9 +162,11 @@ private fun MediaRow(
@PreviewsDayNight
@Composable
internal fun MediaDeleteConfirmationBottomSheetPreview() = ElementPreview {
internal fun MediaDeleteConfirmationBottomSheetPreview(
@PreviewParameter(provider = MediaBottomSheetStateDeleteConfirmationProvider::class) state: MediaBottomSheetState.DeleteConfirmation,
) = ElementPreview {
MediaDeleteConfirmationBottomSheet(
state = aMediaDeleteConfirmationState(),
state = state,
onDelete = {},
onDismiss = {},
)

View file

@ -10,6 +10,7 @@ package io.element.android.libraries.mediaviewer.impl.details
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@ -24,10 +25,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
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.mimetype.MimeTypes
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
@ -45,15 +51,20 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.impl.R
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.strings.Strings
/**
* Ref: https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2229-149220
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MediaDetailsBottomSheet(
state: MediaBottomSheetState.MediaDetailsBottomSheetState,
state: MediaBottomSheetState.Details,
onViewInTimeline: (EventId) -> Unit,
onShare: (EventId) -> Unit,
onForward: (EventId) -> Unit,
onDownload: (EventId) -> Unit,
onOpenWith: (EventId) -> Unit,
onDelete: (EventId) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
@ -66,9 +77,8 @@ fun MediaDetailsBottomSheet(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
Title()
Section(
title = stringResource(R.string.screen_media_details_uploaded_by),
) {
@ -86,57 +96,75 @@ fun MediaDetailsBottomSheet(
)
SectionText(
title = stringResource(R.string.screen_media_details_file_format),
text = state.mediaInfo.mimeType + " - " + state.mediaInfo.formattedFileSize,
text = state.mediaInfo.mimeType + Strings.NICE_SEPARATOR + state.mediaInfo.formattedFileSize,
)
Spacer(modifier = Modifier.height(16.dp))
if (state.eventId != null) {
Column {
HorizontalDivider()
ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.VisibilityOn())),
headlineContent = { Text(stringResource(CommonStrings.action_view_in_timeline)) },
style = ListItemStyle.Primary,
onClick = {
onViewInTimeline(state.eventId)
}
)
ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ShareAndroid())),
headlineContent = { Text(stringResource(CommonStrings.action_share)) },
style = ListItemStyle.Primary,
onClick = {
onShare(state.eventId)
}
)
ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Forward())),
headlineContent = { Text(stringResource(CommonStrings.action_forward)) },
style = ListItemStyle.Primary,
onClick = {
onForward(state.eventId)
}
)
ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Download())),
headlineContent = { Text(stringResource(CommonStrings.action_download)) },
style = ListItemStyle.Primary,
onClick = {
onDownload(state.eventId)
}
)
val mimeType = state.mediaInfo.mimeType
val icon = when (mimeType) {
MimeTypes.Apk ->
ListItemContent.Icon(IconSource.Resource(R.drawable.ic_apk_install))
else ->
ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut()))
}
val wording = when (mimeType) {
MimeTypes.Apk -> stringResource(id = CommonStrings.common_install_apk_android)
else -> stringResource(id = CommonStrings.action_open_with)
}
ListItem(
leadingContent = icon,
headlineContent = { Text(wording) },
style = ListItemStyle.Primary,
onClick = {
onOpenWith(state.eventId)
}
)
if (state.canDelete) {
HorizontalDivider()
ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.VisibilityOn())),
headlineContent = { Text(stringResource(CommonStrings.action_view_in_timeline)) },
style = ListItemStyle.Primary,
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Delete())),
headlineContent = { Text(stringResource(CommonStrings.action_delete)) },
style = ListItemStyle.Destructive,
onClick = {
onViewInTimeline(state.eventId)
onDelete(state.eventId)
}
)
ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ShareAndroid())),
headlineContent = { Text(stringResource(CommonStrings.action_share)) },
style = ListItemStyle.Primary,
onClick = {
onShare(state.eventId)
}
)
ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Forward())),
headlineContent = { Text(stringResource(CommonStrings.action_forward)) },
style = ListItemStyle.Primary,
onClick = {
onForward(state.eventId)
}
)
ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Download())),
headlineContent = { Text(stringResource(CommonStrings.action_save)) },
style = ListItemStyle.Primary,
onClick = {
onDownload(state.eventId)
}
)
if (state.canDelete) {
HorizontalDivider()
ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Delete())),
headlineContent = { Text(stringResource(CommonStrings.action_remove)) },
style = ListItemStyle.Destructive,
onClick = {
onDelete(state.eventId)
}
)
}
Spacer(modifier = Modifier.height(16.dp))
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
@ -167,27 +195,46 @@ private fun SenderRow(
.weight(1f),
) {
// Name
val bestName = mediaInfo.senderName ?: mediaInfo.senderId?.value.orEmpty()
val avatarColors = AvatarColorsProvider.provide(id)
Text(
modifier = Modifier.clipToBounds(),
text = mediaInfo.senderName.orEmpty(),
text = bestName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = avatarColors.foreground,
style = ElementTheme.typography.fontBodyMdMedium,
)
// Id
Text(
text = mediaInfo.senderId?.value.orEmpty(),
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyMdRegular,
)
if (!mediaInfo.senderName.isNullOrEmpty()) {
Text(
text = mediaInfo.senderId?.value.orEmpty(),
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyMdRegular,
)
}
}
}
}
@Composable
private fun ColumnScope.Title() {
Text(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(top = 16.dp, bottom = 8.dp, start = 16.dp, end = 16.dp)
.semantics {
heading()
},
text = stringResource(R.string.screen_media_details_title),
textAlign = TextAlign.Center,
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
)
}
@Composable
private fun Section(
title: String,
@ -196,12 +243,12 @@ private fun Section(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
.padding(vertical = 8.dp, horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = title.uppercase(),
style = ElementTheme.typography.fontBodySmRegular,
text = title,
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textSecondary,
)
content()
@ -224,13 +271,16 @@ private fun SectionText(
@PreviewsDayNight
@Composable
internal fun MediaDetailsBottomSheetPreview() = ElementPreview {
internal fun MediaDetailsBottomSheetPreview(
@PreviewParameter(MediaBottomSheetStateDetailsProvider::class) state: MediaBottomSheetState.Details,
) = ElementPreview {
MediaDetailsBottomSheet(
state = aMediaDetailsBottomSheetState(),
state = state,
onViewInTimeline = {},
onShare = {},
onForward = {},
onDownload = {},
onOpenWith = {},
onDelete = {},
onDismiss = {},
)

View file

@ -1,37 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.details
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
fun aMediaDetailsBottomSheetState(
dateSentFull: String = "December 6, 2024 at 12:59",
canDelete: Boolean = true,
): MediaBottomSheetState.MediaDetailsBottomSheetState {
return MediaBottomSheetState.MediaDetailsBottomSheetState(
eventId = EventId("\$eventId"),
canDelete = canDelete,
mediaInfo = anImageMediaInfo(
senderName = "Alice",
dateSentFull = dateSentFull,
),
thumbnailSource = null,
)
}
fun aMediaDeleteConfirmationState(): MediaBottomSheetState.MediaDeleteConfirmationState {
return MediaBottomSheetState.MediaDeleteConfirmationState(
eventId = EventId("\$eventId"),
mediaInfo = anImageMediaInfo(
senderName = "Alice",
),
thumbnailSource = null,
)
}

View file

@ -14,21 +14,22 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
sealed interface MediaGalleryEvents {
data class ChangeMode(val mode: MediaGalleryMode) : MediaGalleryEvents
data class LoadMore(val direction: Timeline.PaginationDirection) : MediaGalleryEvents
data class Share(val eventId: EventId) : MediaGalleryEvents
data class Forward(val eventId: EventId) : MediaGalleryEvents
data class SaveOnDisk(val eventId: EventId) : MediaGalleryEvents
data class OpenInfo(val mediaItem: MediaItem.Event) : MediaGalleryEvents
data class ViewInTimeline(val eventId: EventId) : MediaGalleryEvents
sealed interface MediaGalleryEvent {
data class ChangeMode(val mode: MediaGalleryMode) : MediaGalleryEvent
data class LoadMore(val direction: Timeline.PaginationDirection) : MediaGalleryEvent
data class Share(val eventId: EventId) : MediaGalleryEvent
data class Forward(val eventId: EventId) : MediaGalleryEvent
data class SaveOnDisk(val eventId: EventId) : MediaGalleryEvent
data class OpenWith(val eventId: EventId) : MediaGalleryEvent
data class OpenInfo(val mediaItem: MediaItem.Event) : MediaGalleryEvent
data class ViewInTimeline(val eventId: EventId) : MediaGalleryEvent
data class ConfirmDelete(
val eventId: EventId,
val mediaInfo: MediaInfo,
val thumbnailSource: MediaSource?,
) : MediaGalleryEvents
) : MediaGalleryEvent
data object CloseBottomSheet : MediaGalleryEvents
data class Delete(val eventId: EventId) : MediaGalleryEvents
data object CloseBottomSheet : MediaGalleryEvent
data class Delete(val eventId: EventId) : MediaGalleryEvent
}

View file

@ -88,39 +88,45 @@ class MediaGalleryPresenter(
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
localMediaActions.Configure()
fun handleEvent(event: MediaGalleryEvents) {
fun handleEvent(event: MediaGalleryEvent) {
when (event) {
is MediaGalleryEvents.ChangeMode -> {
is MediaGalleryEvent.ChangeMode -> {
mode = event.mode
}
is MediaGalleryEvents.LoadMore -> coroutineScope.launch {
is MediaGalleryEvent.LoadMore -> coroutineScope.launch {
mediaGalleryDataSource.loadMore(event.direction)
}
is MediaGalleryEvents.Delete -> coroutineScope.launch {
is MediaGalleryEvent.Delete -> coroutineScope.launch {
mediaGalleryDataSource.deleteItem(event.eventId)
}
is MediaGalleryEvents.SaveOnDisk -> coroutineScope.launch {
is MediaGalleryEvent.SaveOnDisk -> coroutineScope.launch {
mediaBottomSheetState = MediaBottomSheetState.Hidden
groupedMediaItems.dataOrNull().find(event.eventId)?.let {
saveOnDisk(it)
}
}
is MediaGalleryEvents.Share -> coroutineScope.launch {
is MediaGalleryEvent.OpenWith -> coroutineScope.launch {
mediaBottomSheetState = MediaBottomSheetState.Hidden
groupedMediaItems.dataOrNull().find(event.eventId)?.let {
openWith(it)
}
}
is MediaGalleryEvent.Share -> coroutineScope.launch {
mediaBottomSheetState = MediaBottomSheetState.Hidden
groupedMediaItems.dataOrNull().find(event.eventId)?.let {
share(it)
}
}
is MediaGalleryEvents.Forward -> {
is MediaGalleryEvent.Forward -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
navigator.onForwardClick(event.eventId)
}
is MediaGalleryEvents.ViewInTimeline -> {
is MediaGalleryEvent.ViewInTimeline -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
navigator.onViewInTimelineClick(event.eventId)
}
is MediaGalleryEvents.OpenInfo -> coroutineScope.launch {
mediaBottomSheetState = MediaBottomSheetState.MediaDetailsBottomSheetState(
is MediaGalleryEvent.OpenInfo -> coroutineScope.launch {
mediaBottomSheetState = MediaBottomSheetState.Details(
eventId = event.mediaItem.eventId(),
canDelete = when (event.mediaItem.mediaInfo().senderId) {
null -> false
@ -137,14 +143,14 @@ class MediaGalleryPresenter(
},
)
}
is MediaGalleryEvents.ConfirmDelete -> {
mediaBottomSheetState = MediaBottomSheetState.MediaDeleteConfirmationState(
is MediaGalleryEvent.ConfirmDelete -> {
mediaBottomSheetState = MediaBottomSheetState.DeleteConfirmation(
eventId = event.eventId,
mediaInfo = event.mediaInfo,
thumbnailSource = event.thumbnailSource,
)
}
MediaGalleryEvents.CloseBottomSheet -> {
MediaGalleryEvent.CloseBottomSheet -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
}
}
@ -200,6 +206,17 @@ class MediaGalleryPresenter(
}
}
private suspend fun openWith(mediaItem: MediaItem.Event) {
downloadMedia(mediaItem)
.mapCatchingExceptions { localMedia ->
localMediaActions.open(localMedia)
}
.onFailure {
val snackbarMessage = SnackbarMessage(mediaActionsError(it))
snackbarDispatcher.post(snackbarMessage)
}
}
private fun mediaActionsError(throwable: Throwable): Int {
return if (throwable is ActivityNotFoundException) {
R.string.error_no_compatible_app_found

View file

@ -20,7 +20,7 @@ data class MediaGalleryState(
val groupedMediaItems: AsyncData<GroupedMediaItems>,
val mediaBottomSheetState: MediaBottomSheetState,
val snackbarMessage: SnackbarMessage?,
val eventSink: (MediaGalleryEvents) -> Unit,
val eventSink: (MediaGalleryEvent) -> Unit,
)
enum class MediaGalleryMode(val stringResource: Int) {

View file

@ -13,7 +13,7 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.media.WaveFormSamples
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState
import io.element.android.libraries.mediaviewer.impl.details.aMediaBottomSheetStateDetails
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemAudio
@ -79,7 +79,7 @@ open class MediaGalleryStateProvider : PreviewParameterProvider<MediaGalleryStat
)
),
),
aMediaGalleryState(mediaBottomSheetState = aMediaDetailsBottomSheetState()),
aMediaGalleryState(mediaBottomSheetState = aMediaBottomSheetStateDetails()),
aMediaGalleryState(
groupedMediaItems = AsyncData.Failure(Exception("Failed to load media")),
),

View file

@ -132,7 +132,7 @@ fun MediaGalleryView(
index = mode.ordinal,
count = MediaGalleryMode.entries.size,
selected = state.mode == mode,
onClick = { state.eventSink(MediaGalleryEvents.ChangeMode(mode)) },
onClick = { state.eventSink(MediaGalleryEvent.ChangeMode(mode)) },
text = stringResource(mode.stringResource),
)
}
@ -158,24 +158,27 @@ fun MediaGalleryView(
}
when (val bottomSheetState = state.mediaBottomSheetState) {
MediaBottomSheetState.Hidden -> Unit
is MediaBottomSheetState.MediaDetailsBottomSheetState -> {
is MediaBottomSheetState.Details -> {
MediaDetailsBottomSheet(
state = bottomSheetState,
onViewInTimeline = { eventId ->
state.eventSink(MediaGalleryEvents.ViewInTimeline(eventId))
state.eventSink(MediaGalleryEvent.ViewInTimeline(eventId))
},
onShare = { eventId ->
state.eventSink(MediaGalleryEvents.Share(eventId))
state.eventSink(MediaGalleryEvent.Share(eventId))
},
onForward = { eventId ->
state.eventSink(MediaGalleryEvents.Forward(eventId))
state.eventSink(MediaGalleryEvent.Forward(eventId))
},
onDownload = { eventId ->
state.eventSink(MediaGalleryEvents.SaveOnDisk(eventId))
state.eventSink(MediaGalleryEvent.SaveOnDisk(eventId))
},
onOpenWith = { eventId ->
state.eventSink(MediaGalleryEvent.OpenWith(eventId))
},
onDelete = { eventId ->
state.eventSink(
MediaGalleryEvents.ConfirmDelete(
MediaGalleryEvent.ConfirmDelete(
eventId = eventId,
mediaInfo = bottomSheetState.mediaInfo,
thumbnailSource = bottomSheetState.thumbnailSource,
@ -183,18 +186,18 @@ fun MediaGalleryView(
)
},
onDismiss = {
state.eventSink(MediaGalleryEvents.CloseBottomSheet)
state.eventSink(MediaGalleryEvent.CloseBottomSheet)
},
)
}
is MediaBottomSheetState.MediaDeleteConfirmationState -> {
is MediaBottomSheetState.DeleteConfirmation -> {
MediaDeleteConfirmationBottomSheet(
state = bottomSheetState,
onDelete = {
state.eventSink(MediaGalleryEvents.Delete(it))
state.eventSink(MediaGalleryEvent.Delete(it))
},
onDismiss = {
state.eventSink(MediaGalleryEvents.CloseBottomSheet)
state.eventSink(MediaGalleryEvent.CloseBottomSheet)
},
)
}
@ -213,7 +216,7 @@ private fun MediaGalleryPage(
val loadingItem = groupedMediaItems.dataOrNull()?.getItems(mode)?.singleOrNull() as? MediaItem.LoadingIndicator
if (loadingItem != null) {
LaunchedEffect(loadingItem.timestamp) {
state.eventSink(MediaGalleryEvents.LoadMore(loadingItem.direction))
state.eventSink(MediaGalleryEvent.LoadMore(loadingItem.direction))
}
}
LoadingContent(mode)
@ -258,7 +261,7 @@ private fun AsyncData<GroupedMediaItems>.isLoadingItems(mode: MediaGalleryMode):
@Composable
private fun MediaGalleryImages(
imagesAndVideos: ImmutableList<MediaItem>,
eventSink: (MediaGalleryEvents) -> Unit,
eventSink: (MediaGalleryEvent) -> Unit,
onItemClick: (MediaItem.Event) -> Unit,
) {
if (imagesAndVideos.isEmpty()) {
@ -279,7 +282,7 @@ private fun MediaGalleryImages(
@Composable
private fun MediaGalleryFiles(
files: ImmutableList<MediaItem>,
eventSink: (MediaGalleryEvents) -> Unit,
eventSink: (MediaGalleryEvent) -> Unit,
onItemClick: (MediaItem.Event) -> Unit,
) {
if (files.isEmpty()) {
@ -300,7 +303,7 @@ private fun MediaGalleryFiles(
@Composable
private fun MediaGalleryFilesList(
files: ImmutableList<MediaItem>,
eventSink: (MediaGalleryEvents) -> Unit,
eventSink: (MediaGalleryEvent) -> Unit,
onItemClick: (MediaItem.Event) -> Unit,
) {
val presenterFactories = LocalMediaItemPresenterFactories.current
@ -318,7 +321,7 @@ private fun MediaGalleryFilesList(
file = item,
onClick = { onItemClick(item) },
onLongClick = {
eventSink(MediaGalleryEvents.OpenInfo(item))
eventSink(MediaGalleryEvent.OpenInfo(item))
},
)
is MediaItem.Audio -> AudioItemView(
@ -326,7 +329,7 @@ private fun MediaGalleryFilesList(
audio = item,
onClick = { onItemClick(item) },
onLongClick = {
eventSink(MediaGalleryEvents.OpenInfo(item))
eventSink(MediaGalleryEvent.OpenInfo(item))
},
)
is MediaItem.Voice -> {
@ -336,7 +339,7 @@ private fun MediaGalleryFilesList(
state = presenter.present(),
voice = item,
onLongClick = {
eventSink(MediaGalleryEvents.OpenInfo(item))
eventSink(MediaGalleryEvent.OpenInfo(item))
},
)
}
@ -361,7 +364,7 @@ private fun MediaGalleryFilesList(
@Composable
private fun MediaGalleryImageGrid(
imagesAndVideos: ImmutableList<MediaItem>,
eventSink: (MediaGalleryEvents) -> Unit,
eventSink: (MediaGalleryEvent) -> Unit,
onItemClick: (MediaItem.Event) -> Unit,
) {
LazyVerticalGrid(
@ -403,7 +406,7 @@ private fun MediaGalleryImageGrid(
image = item,
onClick = { onItemClick(item) },
onLongClick = {
eventSink(MediaGalleryEvents.OpenInfo(item))
eventSink(MediaGalleryEvent.OpenInfo(item))
},
)
is MediaItem.Video -> VideoItemView(
@ -411,7 +414,7 @@ private fun MediaGalleryImageGrid(
video = item,
onClick = { onItemClick(item) },
onLongClick = {
eventSink(MediaGalleryEvents.OpenInfo(item))
eventSink(MediaGalleryEvent.OpenInfo(item))
},
)
is MediaItem.LoadingIndicator -> LoadingMoreIndicator(
@ -427,7 +430,7 @@ private fun MediaGalleryImageGrid(
@Composable
private fun LoadingMoreIndicator(
item: MediaItem.LoadingIndicator,
eventSink: (MediaGalleryEvents) -> Unit,
eventSink: (MediaGalleryEvent) -> Unit,
modifier: Modifier = Modifier
) {
Box(
@ -452,7 +455,7 @@ private fun LoadingMoreIndicator(
}
val latestEventSink by rememberUpdatedState(eventSink)
LaunchedEffect(item.timestamp) {
latestEventSink(MediaGalleryEvents.LoadMore(item.direction))
latestEventSink(MediaGalleryEvent.LoadMore(item.direction))
}
}
}

View file

@ -9,6 +9,7 @@
package io.element.android.libraries.mediaviewer.impl.local.audio
import androidx.media3.common.MediaMetadata
import io.element.android.libraries.ui.strings.Strings
fun MediaMetadata?.hasArtwork(): Boolean {
return this?.artworkData != null || this?.artworkUri != null
@ -22,13 +23,13 @@ fun MediaMetadata?.buildInfo(): String {
}
if (title != null) {
if (isNotEmpty()) {
append(" - ")
append(Strings.NICE_SEPARATOR)
}
append(title)
}
if (recordingYear != null) {
if (isNotEmpty()) {
append(" - ")
append(Strings.NICE_SEPARATOR)
}
append(recordingYear)
}

View file

@ -11,22 +11,22 @@ package io.element.android.libraries.mediaviewer.impl.viewer
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
sealed interface MediaViewerEvents {
data class LoadMedia(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data class SaveOnDisk(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data class Share(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data class OpenWith(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data class ClearLoadingError(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data class ViewInTimeline(val eventId: EventId) : MediaViewerEvents
data class Forward(val eventId: EventId) : MediaViewerEvents
data class OpenInfo(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
sealed interface MediaViewerEvent {
data class LoadMedia(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvent
data class SaveOnDisk(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvent
data class Share(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvent
data class OpenWith(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvent
data class ClearLoadingError(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvent
data class ViewInTimeline(val eventId: EventId) : MediaViewerEvent
data class Forward(val eventId: EventId) : MediaViewerEvent
data class OpenInfo(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvent
data class ConfirmDelete(
val eventId: EventId,
val data: MediaViewerPageData.MediaViewerData,
) : MediaViewerEvents
) : MediaViewerEvent
data object CloseBottomSheet : MediaViewerEvents
data class Delete(val eventId: EventId) : MediaViewerEvents
data class OnNavigateTo(val index: Int) : MediaViewerEvents
data class LoadMore(val direction: Timeline.PaginationDirection) : MediaViewerEvents
data object CloseBottomSheet : MediaViewerEvent
data class Delete(val eventId: EventId) : MediaViewerEvent
data class OnNavigateTo(val index: Int) : MediaViewerEvent
data class LoadMore(val direction: Timeline.PaginationDirection) : MediaViewerEvent
}

View file

@ -95,43 +95,43 @@ class MediaViewerPresenter(
}
localMediaActions.Configure()
fun handleEvent(event: MediaViewerEvents) {
fun handleEvent(event: MediaViewerEvent) {
when (event) {
is MediaViewerEvents.LoadMedia -> {
is MediaViewerEvent.LoadMedia -> {
coroutineScope.downloadMedia(data = event.data)
}
is MediaViewerEvents.ClearLoadingError -> {
is MediaViewerEvent.ClearLoadingError -> {
dataSource.clearLoadingError(event.data)
}
is MediaViewerEvents.SaveOnDisk -> {
is MediaViewerEvent.SaveOnDisk -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
coroutineScope.saveOnDisk(event.data.downloadedMedia.value)
}
is MediaViewerEvents.Share -> {
is MediaViewerEvent.Share -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
coroutineScope.share(event.data.downloadedMedia.value)
}
is MediaViewerEvents.OpenWith -> {
is MediaViewerEvent.OpenWith -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
coroutineScope.open(event.data.downloadedMedia.value)
}
is MediaViewerEvents.Delete -> {
is MediaViewerEvent.Delete -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
coroutineScope.delete(event.eventId)
}
is MediaViewerEvents.ViewInTimeline -> {
is MediaViewerEvent.ViewInTimeline -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
navigator.onViewInTimelineClick(event.eventId)
}
is MediaViewerEvents.Forward -> {
is MediaViewerEvent.Forward -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
navigator.onForwardClick(
eventId = event.eventId,
fromPinnedEvents = inputs.mode.getTimelineMode() == Timeline.Mode.PinnedEvents,
)
}
is MediaViewerEvents.OpenInfo -> coroutineScope.launch {
mediaBottomSheetState = MediaBottomSheetState.MediaDetailsBottomSheetState(
is MediaViewerEvent.OpenInfo -> coroutineScope.launch {
mediaBottomSheetState = MediaBottomSheetState.Details(
eventId = event.data.eventId,
canDelete = when (event.data.mediaInfo.senderId) {
null -> false
@ -142,20 +142,20 @@ class MediaViewerPresenter(
thumbnailSource = event.data.thumbnailSource,
)
}
is MediaViewerEvents.ConfirmDelete -> {
mediaBottomSheetState = MediaBottomSheetState.MediaDeleteConfirmationState(
is MediaViewerEvent.ConfirmDelete -> {
mediaBottomSheetState = MediaBottomSheetState.DeleteConfirmation(
eventId = event.eventId,
mediaInfo = event.data.mediaInfo,
thumbnailSource = event.data.thumbnailSource ?: event.data.mediaSource,
)
}
MediaViewerEvents.CloseBottomSheet -> {
MediaViewerEvent.CloseBottomSheet -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
}
is MediaViewerEvents.OnNavigateTo -> {
is MediaViewerEvent.OnNavigateTo -> {
currentIndex.intValue = event.index
}
is MediaViewerEvents.LoadMore -> coroutineScope.launch {
is MediaViewerEvent.LoadMore -> coroutineScope.launch {
dataSource.loadMore(event.direction)
}
}

View file

@ -26,7 +26,7 @@ data class MediaViewerState(
val snackbarMessage: SnackbarMessage?,
val canShowInfo: Boolean,
val mediaBottomSheetState: MediaBottomSheetState,
val eventSink: (MediaViewerEvents) -> Unit,
val eventSink: (MediaViewerEvent) -> Unit,
)
sealed interface MediaViewerPageData {

View file

@ -25,8 +25,8 @@ import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
import io.element.android.libraries.mediaviewer.impl.details.aMediaDeleteConfirmationState
import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState
import io.element.android.libraries.mediaviewer.impl.details.aMediaBottomSheetStateDeleteConfirmation
import io.element.android.libraries.mediaviewer.impl.details.aMediaBottomSheetStateDetails
import kotlinx.collections.immutable.toImmutableList
private const val LONG_CAPTION = "This is a very long caption that should be scrollable in the media viewer. " +
@ -141,10 +141,10 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
)
},
aMediaViewerState(
mediaBottomSheetState = aMediaDetailsBottomSheetState(),
mediaBottomSheetState = aMediaBottomSheetStateDetails(),
),
aMediaViewerState(
mediaBottomSheetState = aMediaDeleteConfirmationState(),
mediaBottomSheetState = aMediaBottomSheetStateDeleteConfirmation(),
),
anAudioMediaInfo(
waveForm = WaveFormSamples.realisticWaveForm,
@ -226,7 +226,7 @@ fun aMediaViewerState(
currentIndex: Int = 0,
canShowInfo: Boolean = true,
mediaBottomSheetState: MediaBottomSheetState = MediaBottomSheetState.Hidden,
eventSink: (MediaViewerEvents) -> Unit = {},
eventSink: (MediaViewerEvent) -> Unit = {},
) = MediaViewerState(
initiallySelectedEventId = EventId("\$a:b"),
listData = listData.toImmutableList(),

View file

@ -65,7 +65,6 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.viewfolder.api.TextFileViewer
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.audio.api.AudioFocus
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.designsystem.components.async.AsyncFailure
import io.element.android.libraries.designsystem.components.async.AsyncLoading
@ -85,7 +84,6 @@ import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
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
import io.element.android.libraries.mediaviewer.impl.details.MediaDetailsBottomSheet
@ -101,6 +99,9 @@ import me.saket.telephoto.zoomable.rememberZoomableState
val topAppBarHeight = 88.dp
/**
* Ref: https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=3361-16623
*/
@Composable
fun MediaViewerView(
state: MediaViewerState,
@ -126,7 +127,7 @@ fun MediaViewerView(
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect { page ->
state.eventSink(MediaViewerEvents.OnNavigateTo(page))
state.eventSink(MediaViewerEvent.OnNavigateTo(page))
}
}
HorizontalPager(
@ -145,7 +146,7 @@ fun MediaViewerView(
}
is MediaViewerPageData.Loading -> {
LaunchedEffect(dataForPage.timestamp) {
state.eventSink(MediaViewerEvents.LoadMore(dataForPage.direction))
state.eventSink(MediaViewerEvent.LoadMore(dataForPage.direction))
}
MediaViewerLoadingPage(
onDismiss = onBackClick,
@ -154,7 +155,7 @@ fun MediaViewerView(
is MediaViewerPageData.MediaViewerData -> {
var bottomPaddingInPixels by remember { mutableIntStateOf(defaultBottomPaddingInPixels) }
LaunchedEffect(Unit) {
state.eventSink(MediaViewerEvents.LoadMedia(dataForPage))
state.eventSink(MediaViewerEvent.LoadMedia(dataForPage))
}
Box(
modifier = Modifier.fillMaxSize()
@ -173,10 +174,10 @@ fun MediaViewerView(
textFileViewer = textFileViewer,
onDismiss = onBackClick,
onRetry = {
state.eventSink(MediaViewerEvents.LoadMedia(dataForPage))
state.eventSink(MediaViewerEvent.LoadMedia(dataForPage))
},
onDismissError = {
state.eventSink(MediaViewerEvents.ClearLoadingError(dataForPage))
state.eventSink(MediaViewerEvent.ClearLoadingError(dataForPage))
},
onShowOverlayChange = {
showOverlay = it
@ -214,10 +215,15 @@ fun MediaViewerView(
data = currentData,
canShowInfo = state.canShowInfo,
onBackClick = onBackClick,
onInfoClick = {
state.eventSink(MediaViewerEvents.OpenInfo(currentData))
onShareClick = {
state.eventSink(MediaViewerEvent.Share(currentData))
},
onSaveClick = {
state.eventSink(MediaViewerEvent.SaveOnDisk(currentData))
},
onInfoClick = {
state.eventSink(MediaViewerEvent.OpenInfo(currentData))
},
eventSink = state.eventSink
)
}
else -> {
@ -247,29 +253,34 @@ fun MediaViewerView(
when (val bottomSheetState = state.mediaBottomSheetState) {
MediaBottomSheetState.Hidden -> Unit
is MediaBottomSheetState.MediaDetailsBottomSheetState -> {
is MediaBottomSheetState.Details -> {
MediaDetailsBottomSheet(
state = bottomSheetState,
onViewInTimeline = {
state.eventSink(MediaViewerEvents.ViewInTimeline(it))
state.eventSink(MediaViewerEvent.ViewInTimeline(it))
},
onShare = {
(currentData as? MediaViewerPageData.MediaViewerData)?.let {
state.eventSink(MediaViewerEvents.Share(currentData))
state.eventSink(MediaViewerEvent.Share(currentData))
}
},
onForward = {
state.eventSink(MediaViewerEvents.Forward(it))
state.eventSink(MediaViewerEvent.Forward(it))
},
onDownload = {
(currentData as? MediaViewerPageData.MediaViewerData)?.let {
state.eventSink(MediaViewerEvents.SaveOnDisk(currentData))
state.eventSink(MediaViewerEvent.SaveOnDisk(currentData))
}
},
onOpenWith = {
(currentData as? MediaViewerPageData.MediaViewerData)?.let {
state.eventSink(MediaViewerEvent.OpenWith(currentData))
}
},
onDelete = { eventId ->
(currentData as? MediaViewerPageData.MediaViewerData)?.let {
state.eventSink(
MediaViewerEvents.ConfirmDelete(
MediaViewerEvent.ConfirmDelete(
eventId,
currentData,
)
@ -277,18 +288,18 @@ fun MediaViewerView(
}
},
onDismiss = {
state.eventSink(MediaViewerEvents.CloseBottomSheet)
state.eventSink(MediaViewerEvent.CloseBottomSheet)
},
)
}
is MediaBottomSheetState.MediaDeleteConfirmationState -> {
is MediaBottomSheetState.DeleteConfirmation -> {
MediaDeleteConfirmationBottomSheet(
state = bottomSheetState,
onDelete = {
state.eventSink(MediaViewerEvents.Delete(it))
state.eventSink(MediaViewerEvent.Delete(it))
},
onDismiss = {
state.eventSink(MediaViewerEvents.CloseBottomSheet)
state.eventSink(MediaViewerEvent.CloseBottomSheet)
},
)
}
@ -457,12 +468,12 @@ private fun MediaViewerTopBar(
data: MediaViewerPageData.MediaViewerData,
canShowInfo: Boolean,
onBackClick: () -> Unit,
onShareClick: () -> Unit,
onSaveClick: () -> Unit,
onInfoClick: () -> Unit,
eventSink: (MediaViewerEvents) -> Unit,
) {
val downloadedMedia by data.downloadedMedia
val actionsEnabled = downloadedMedia.isSuccess()
val mimeType = data.mediaInfo.mimeType
val senderName = data.mediaInfo.senderName
val dateSent = data.mediaInfo.dateSent
TopAppBar(
@ -498,21 +509,22 @@ private fun MediaViewerTopBar(
navigationIcon = { BackButton(onClick = onBackClick) },
actions = {
IconButton(
onClick = onShareClick,
enabled = actionsEnabled,
onClick = {
eventSink(MediaViewerEvents.OpenWith(data))
},
) {
when (mimeType) {
MimeTypes.Apk -> Icon(
resourceId = R.drawable.ic_apk_install,
contentDescription = stringResource(id = CommonStrings.common_install_apk_android)
)
else -> Icon(
imageVector = CompoundIcons.PopOut(),
contentDescription = stringResource(id = CommonStrings.action_open_with)
)
}
Icon(
imageVector = CompoundIcons.ShareAndroid(),
contentDescription = stringResource(id = CommonStrings.action_share),
)
}
IconButton(
onClick = onSaveClick,
enabled = actionsEnabled,
) {
Icon(
imageVector = CompoundIcons.Download(),
contentDescription = stringResource(id = CommonStrings.action_download),
)
}
if (canShowInfo) {
IconButton(

View file

@ -12,10 +12,11 @@
<string name="screen_media_browser_media_empty_state_subtitle">"Images and videos uploaded to this room will be shown here."</string>
<string name="screen_media_browser_media_empty_state_title">"No media uploaded yet"</string>
<string name="screen_media_browser_title">"Media and files"</string>
<string name="screen_media_details_file_format">"File format"</string>
<string name="screen_media_details_filename">"File name"</string>
<string name="screen_media_details_file_format">"Format"</string>
<string name="screen_media_details_filename">"Name"</string>
<string name="screen_media_details_no_more_files_to_show">"No more files to show"</string>
<string name="screen_media_details_no_more_media_to_show">"No more media to show"</string>
<string name="screen_media_details_title">"File info"</string>
<string name="screen_media_details_uploaded_by">"Uploaded by"</string>
<string name="screen_media_details_uploaded_on">"Uploaded on"</string>
</resources>

View file

@ -33,7 +33,7 @@ class MediaDeleteConfirmationBottomSheetTest {
@Test
fun `clicking on Cancel invokes expected callback`() {
val state = aMediaDeleteConfirmationState()
val state = aMediaBottomSheetStateDeleteConfirmation()
ensureCalledOnce { callback ->
rule.setMediaDeleteConfirmationBottomSheet(
state = state,
@ -45,7 +45,7 @@ class MediaDeleteConfirmationBottomSheetTest {
@Test
fun `clicking on Remove invokes expected callback`() {
val state = aMediaDeleteConfirmationState()
val state = aMediaBottomSheetStateDeleteConfirmation()
ensureCalledOnceWithParam(state.eventId) { callback ->
rule.setMediaDeleteConfirmationBottomSheet(
state = state,
@ -58,7 +58,7 @@ class MediaDeleteConfirmationBottomSheetTest {
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMediaDeleteConfirmationBottomSheet(
state: MediaBottomSheetState.MediaDeleteConfirmationState,
state: MediaBottomSheetState.DeleteConfirmation,
onDelete: (EventId) -> Unit = EnsureNeverCalledWithParam(),
onDismiss: () -> Unit = EnsureNeverCalled(),
) {

View file

@ -34,7 +34,7 @@ class MediaDetailsBottomSheetTest {
@Test
@Config(qualifiers = "h1024dp")
fun `clicking on View in timeline invokes expected callback`() {
val state = aMediaDetailsBottomSheetState()
val state = aMediaBottomSheetStateDetails()
ensureCalledOnceWithParam(state.eventId) { callback ->
rule.setMediaDetailsBottomSheet(
state = state,
@ -47,7 +47,7 @@ class MediaDetailsBottomSheetTest {
@Test
@Config(qualifiers = "h1024dp")
fun `clicking on Share invokes expected callback`() {
val state = aMediaDetailsBottomSheetState()
val state = aMediaBottomSheetStateDetails()
ensureCalledOnceWithParam(state.eventId) { callback ->
rule.setMediaDetailsBottomSheet(
state = state,
@ -60,7 +60,7 @@ class MediaDetailsBottomSheetTest {
@Test
@Config(qualifiers = "h1024dp")
fun `clicking on Forward invokes expected callback`() {
val state = aMediaDetailsBottomSheetState()
val state = aMediaBottomSheetStateDetails()
ensureCalledOnceWithParam(state.eventId) { callback ->
rule.setMediaDetailsBottomSheet(
state = state,
@ -72,35 +72,35 @@ class MediaDetailsBottomSheetTest {
@Test
@Config(qualifiers = "h1024dp")
fun `clicking on Save invokes expected callback`() {
val state = aMediaDetailsBottomSheetState()
fun `clicking on Download invokes expected callback`() {
val state = aMediaBottomSheetStateDetails()
ensureCalledOnceWithParam(state.eventId) { callback ->
rule.setMediaDetailsBottomSheet(
state = state,
onDownload = callback,
)
rule.clickOn(CommonStrings.action_save)
rule.clickOn(CommonStrings.action_download)
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on Remove invokes expected callback`() {
val state = aMediaDetailsBottomSheetState()
fun `clicking on Delete invokes expected callback`() {
val state = aMediaBottomSheetStateDetails()
ensureCalledOnceWithParam(state.eventId) { callback ->
rule.setMediaDetailsBottomSheet(
state = state,
onDelete = callback,
)
rule.onNodeWithText(rule.activity.getString(CommonStrings.action_remove)).assertExists()
rule.clickOn(CommonStrings.action_remove)
rule.onNodeWithText(rule.activity.getString(CommonStrings.action_delete)).assertExists()
rule.clickOn(CommonStrings.action_delete)
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `Remove is not present if canDelete is false`() {
val state = aMediaDetailsBottomSheetState(
val state = aMediaBottomSheetStateDetails(
canDelete = false,
)
rule.setMediaDetailsBottomSheet(
@ -111,11 +111,12 @@ class MediaDetailsBottomSheetTest {
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMediaDetailsBottomSheet(
state: MediaBottomSheetState.MediaDetailsBottomSheetState,
state: MediaBottomSheetState.Details,
onViewInTimeline: (EventId) -> Unit = EnsureNeverCalledWithParam(),
onShare: (EventId) -> Unit = EnsureNeverCalledWithParam(),
onForward: (EventId) -> Unit = EnsureNeverCalledWithParam(),
onDownload: (EventId) -> Unit = EnsureNeverCalledWithParam(),
onOpenWith: (EventId) -> Unit = EnsureNeverCalledWithParam(),
onDelete: (EventId) -> Unit = EnsureNeverCalledWithParam(),
onDismiss: () -> Unit = EnsureNeverCalled(),
) {
@ -126,6 +127,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMedia
onShare = onShare,
onForward = onForward,
onDownload = onDownload,
onOpenWith = onOpenWith,
onDelete = onDelete,
onDismiss = onDismiss,
)

View file

@ -6,6 +6,8 @@
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.libraries.mediaviewer.impl.gallery
import android.net.Uri
@ -27,6 +29,7 @@ import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.impl.datasource.FakeMediaGalleryDataSource
import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
@ -39,6 +42,8 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@ -52,8 +57,12 @@ class MediaGalleryPresenterTest {
@Test
fun `present - initial state`() = runTest {
val configureLambda = lambdaRecorder<Unit> { }
val startLambda = lambdaRecorder<Unit> { }
val presenter = createMediaGalleryPresenter(
localMediaActions = FakeLocalMediaActions(
configureResult = configureLambda,
),
mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = startLambda,
),
@ -70,6 +79,7 @@ class MediaGalleryPresenterTest {
assertThat(initialState.groupedMediaItems.isUninitialized()).isTrue()
assertThat(initialState.snackbarMessage).isNull()
}
configureLambda.assertions().isCalledOnce()
startLambda.assertions().isCalledOnce()
}
@ -84,10 +94,10 @@ class MediaGalleryPresenterTest {
presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.mode).isEqualTo(MediaGalleryMode.Images)
initialState.eventSink(MediaGalleryEvents.ChangeMode(MediaGalleryMode.Files))
initialState.eventSink(MediaGalleryEvent.ChangeMode(MediaGalleryMode.Files))
val state = awaitItem()
assertThat(state.mode).isEqualTo(MediaGalleryMode.Files)
state.eventSink(MediaGalleryEvents.ChangeMode(MediaGalleryMode.Images))
state.eventSink(MediaGalleryEvent.ChangeMode(MediaGalleryMode.Images))
val imageModeState = awaitItem()
assertThat(imageModeState.mode).isEqualTo(MediaGalleryMode.Images)
}
@ -123,10 +133,10 @@ class MediaGalleryPresenterTest {
eventId = AN_EVENT_ID,
senderId = A_USER_ID,
)
initialState.eventSink(MediaGalleryEvents.OpenInfo(item))
initialState.eventSink(MediaGalleryEvent.OpenInfo(item))
val state = awaitItem()
assertThat(state.mediaBottomSheetState).isEqualTo(
MediaBottomSheetState.MediaDetailsBottomSheetState(
MediaBottomSheetState.Details(
eventId = AN_EVENT_ID,
canDelete = canDeleteOwn,
mediaInfo = item.mediaInfo,
@ -134,7 +144,7 @@ class MediaGalleryPresenterTest {
)
)
// Close the bottom sheet
state.eventSink(MediaGalleryEvents.CloseBottomSheet)
state.eventSink(MediaGalleryEvent.CloseBottomSheet)
val closedState = awaitItem()
assertThat(closedState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
}
@ -170,10 +180,10 @@ class MediaGalleryPresenterTest {
eventId = AN_EVENT_ID,
senderId = A_USER_ID_2,
)
initialState.eventSink(MediaGalleryEvents.OpenInfo(item))
initialState.eventSink(MediaGalleryEvent.OpenInfo(item))
val state = awaitItem()
assertThat(state.mediaBottomSheetState).isEqualTo(
MediaBottomSheetState.MediaDetailsBottomSheetState(
MediaBottomSheetState.Details(
eventId = AN_EVENT_ID,
canDelete = canDeleteOther,
mediaInfo = item.mediaInfo,
@ -181,7 +191,7 @@ class MediaGalleryPresenterTest {
)
)
// Close the bottom sheet
state.eventSink(MediaGalleryEvents.CloseBottomSheet)
state.eventSink(MediaGalleryEvent.CloseBottomSheet)
val closedState = awaitItem()
assertThat(closedState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
}
@ -199,17 +209,17 @@ class MediaGalleryPresenterTest {
val initialState = awaitFirstItem()
// Delete bottom sheet
val item = aMediaItemImage()
initialState.eventSink(MediaGalleryEvents.ConfirmDelete(AN_EVENT_ID, item.mediaInfo, item.thumbnailSource))
initialState.eventSink(MediaGalleryEvent.ConfirmDelete(AN_EVENT_ID, item.mediaInfo, item.thumbnailSource))
val deleteState = awaitItem()
assertThat(deleteState.mediaBottomSheetState).isEqualTo(
MediaBottomSheetState.MediaDeleteConfirmationState(
MediaBottomSheetState.DeleteConfirmation(
eventId = AN_EVENT_ID,
mediaInfo = item.mediaInfo,
thumbnailSource = item.thumbnailSource,
)
)
// Close the bottom sheet
deleteState.eventSink(MediaGalleryEvents.CloseBottomSheet)
deleteState.eventSink(MediaGalleryEvent.CloseBottomSheet)
val deleteClosedState = awaitItem()
assertThat(deleteClosedState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
}
@ -226,7 +236,7 @@ class MediaGalleryPresenterTest {
)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(MediaGalleryEvents.Delete(AN_EVENT_ID))
initialState.eventSink(MediaGalleryEvent.Delete(AN_EVENT_ID))
deleteItemLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
}
}
@ -236,7 +246,7 @@ class MediaGalleryPresenterTest {
val presenter = createMediaGalleryPresenter()
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(MediaGalleryEvents.Share(AN_EVENT_ID))
initialState.eventSink(MediaGalleryEvent.Share(AN_EVENT_ID))
}
}
@ -258,7 +268,7 @@ class MediaGalleryPresenterTest {
)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(MediaGalleryEvents.Share(AN_EVENT_ID))
initialState.eventSink(MediaGalleryEvent.Share(AN_EVENT_ID))
val finalState = awaitItem()
assertThat(finalState.snackbarMessage).isNull()
}
@ -283,7 +293,7 @@ class MediaGalleryPresenterTest {
)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(MediaGalleryEvents.Share(AN_EVENT_ID))
initialState.eventSink(MediaGalleryEvent.Share(AN_EVENT_ID))
skipItems(1)
val finalState = awaitItem()
assertThat(finalState.snackbarMessage).isInstanceOf(SnackbarMessage::class.java)
@ -295,7 +305,7 @@ class MediaGalleryPresenterTest {
val presenter = createMediaGalleryPresenter()
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(MediaGalleryEvents.SaveOnDisk(AN_EVENT_ID))
initialState.eventSink(MediaGalleryEvent.SaveOnDisk(AN_EVENT_ID))
}
}
@ -304,23 +314,89 @@ class MediaGalleryPresenterTest {
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
)
val saveOnDiskResult = lambdaRecorder<LocalMedia, Result<Unit>> { _ -> Result.success(Unit) }
val media = aMediaItemImage(eventId = AN_EVENT_ID)
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
aGroupedMediaItems(
imageAndVideoItems = listOf(aMediaItemImage(eventId = AN_EVENT_ID)),
imageAndVideoItems = listOf(media),
fileItems = emptyList(),
)
)
)
val presenter = createMediaGalleryPresenter(
localMediaActions = FakeLocalMediaActions(
saveOnDiskResult = saveOnDiskResult,
),
mediaGalleryDataSource = mediaGalleryDataSource,
)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(MediaGalleryEvents.SaveOnDisk(AN_EVENT_ID))
initialState.eventSink(MediaGalleryEvent.SaveOnDisk(AN_EVENT_ID))
skipItems(1)
val finalState = awaitItem()
assertThat(finalState.snackbarMessage?.messageResId).isEqualTo(CommonStrings.common_file_saved_on_disk_android)
saveOnDiskResult.assertions().isCalledOnce().with(
value(
LocalMedia(
uri = mockMediaUri,
info = media.mediaInfo,
)
)
)
}
}
@Test
fun `present - open with closes the bottom sheet and invokes the navigator`() = runTest {
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
)
val openWithResult = lambdaRecorder<LocalMedia, Result<Unit>> { _ -> Result.success(Unit) }
val item = aMediaItemImage(
eventId = AN_EVENT_ID,
senderId = A_USER_ID,
)
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
aGroupedMediaItems(
imageAndVideoItems = listOf(item),
fileItems = emptyList(),
)
)
)
val presenter = createMediaGalleryPresenter(
localMediaActions = FakeLocalMediaActions(
openResult = openWithResult,
),
mediaGalleryDataSource = mediaGalleryDataSource,
room = FakeJoinedRoom(
createTimelineResult = { Result.success(FakeTimeline()) },
baseRoom = FakeBaseRoom(
roomPermissions = FakeRoomPermissions(
canRedactOwn = true
),
),
),
)
presenter.test {
skipItems(1)
val initialState = awaitFirstItem()
initialState.eventSink(MediaGalleryEvent.OpenInfo(item))
val withBottomSheetState = awaitItem()
assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.Details::class.java)
withBottomSheetState.eventSink(MediaGalleryEvent.OpenWith(AN_EVENT_ID))
val finalState = awaitItem()
assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
advanceUntilIdle()
openWithResult.assertions().isCalledOnce().with(
value(
LocalMedia(
uri = mockMediaUri,
info = item.mediaInfo,
)
)
)
}
}
@ -343,7 +419,7 @@ class MediaGalleryPresenterTest {
)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(MediaGalleryEvents.SaveOnDisk(AN_EVENT_ID))
initialState.eventSink(MediaGalleryEvent.SaveOnDisk(AN_EVENT_ID))
skipItems(1)
val finalState = awaitItem()
assertThat(finalState.snackbarMessage).isInstanceOf(SnackbarMessage::class.java)
@ -373,10 +449,10 @@ class MediaGalleryPresenterTest {
eventId = AN_EVENT_ID,
senderId = A_USER_ID,
)
initialState.eventSink(MediaGalleryEvents.OpenInfo(item))
initialState.eventSink(MediaGalleryEvent.OpenInfo(item))
val withBottomSheetState = awaitItem()
assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDetailsBottomSheetState::class.java)
withBottomSheetState.eventSink(MediaGalleryEvents.ViewInTimeline(AN_EVENT_ID))
assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.Details::class.java)
withBottomSheetState.eventSink(MediaGalleryEvent.ViewInTimeline(AN_EVENT_ID))
val finalState = awaitItem()
assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
onViewInTimelineClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
@ -406,10 +482,10 @@ class MediaGalleryPresenterTest {
eventId = AN_EVENT_ID,
senderId = A_USER_ID,
)
initialState.eventSink(MediaGalleryEvents.OpenInfo(item))
initialState.eventSink(MediaGalleryEvent.OpenInfo(item))
val withBottomSheetState = awaitItem()
assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDetailsBottomSheetState::class.java)
withBottomSheetState.eventSink(MediaGalleryEvents.Forward(AN_EVENT_ID))
assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.Details::class.java)
withBottomSheetState.eventSink(MediaGalleryEvent.Forward(AN_EVENT_ID))
val finalState = awaitItem()
assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
onForwardClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
@ -427,7 +503,7 @@ class MediaGalleryPresenterTest {
)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(MediaGalleryEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS))
initialState.eventSink(MediaGalleryEvent.LoadMore(Timeline.PaginationDirection.BACKWARDS))
loadMoreLambda.assertions().isCalledOnce().with(value(Timeline.PaginationDirection.BACKWARDS))
}
}

View file

@ -226,7 +226,7 @@ class MediaViewerPresenterTest {
)
val updatedState = awaitItem()
updatedState.eventSink(
MediaViewerEvents.LoadMedia(
MediaViewerEvent.LoadMedia(
aMediaViewerPageData(
mediaSource = MediaSource(aUrl)
)
@ -266,16 +266,16 @@ class MediaViewerPresenterTest {
)
val updatedState = awaitItem()
updatedState.eventSink(
MediaViewerEvents.OpenInfo(
MediaViewerEvent.OpenInfo(
aMediaViewerPageData(
mediaSource = MediaSource(aUrl)
)
)
)
val withInfoState = awaitItem()
assertThat(withInfoState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDetailsBottomSheetState::class.java)
assertThat(withInfoState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.Details::class.java)
withInfoState.eventSink(
MediaViewerEvents.CloseBottomSheet
MediaViewerEvent.CloseBottomSheet
)
val finalState = awaitItem()
assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
@ -306,7 +306,7 @@ class MediaViewerPresenterTest {
)
val updatedState = awaitItem()
updatedState.eventSink(
MediaViewerEvents.ClearLoadingError(
MediaViewerEvent.ClearLoadingError(
aMediaViewerPageData(
mediaSource = MediaSource(aUrl)
)
@ -339,7 +339,7 @@ class MediaViewerPresenterTest {
)
val updatedState = awaitItem()
updatedState.eventSink(
MediaViewerEvents.Share(
MediaViewerEvent.Share(
aMediaViewerPageData(
mediaSource = MediaSource(aUrl)
)
@ -372,7 +372,7 @@ class MediaViewerPresenterTest {
)
val updatedState = awaitItem()
updatedState.eventSink(
MediaViewerEvents.SaveOnDisk(
MediaViewerEvent.SaveOnDisk(
aMediaViewerPageData(
mediaSource = MediaSource(aUrl)
)
@ -405,7 +405,7 @@ class MediaViewerPresenterTest {
)
val updatedState = awaitItem()
updatedState.eventSink(
MediaViewerEvents.OpenWith(
MediaViewerEvent.OpenWith(
aMediaViewerPageData(
mediaSource = MediaSource(aUrl)
)
@ -438,7 +438,7 @@ class MediaViewerPresenterTest {
)
val updatedState = awaitItem()
updatedState.eventSink(
MediaViewerEvents.ConfirmDelete(
MediaViewerEvent.ConfirmDelete(
eventId = AN_EVENT_ID,
data = aMediaViewerPageData(
mediaSource = MediaSource(aUrl)
@ -446,9 +446,9 @@ class MediaViewerPresenterTest {
)
)
val withBottomSheetState = awaitItem()
assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDeleteConfirmationState::class.java)
assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.DeleteConfirmation::class.java)
withBottomSheetState.eventSink(
MediaViewerEvents.CloseBottomSheet
MediaViewerEvent.CloseBottomSheet
)
val finalState = awaitItem()
assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
@ -498,7 +498,7 @@ class MediaViewerPresenterTest {
)
val updatedState = awaitItem()
updatedState.eventSink(
MediaViewerEvents.ConfirmDelete(
MediaViewerEvent.ConfirmDelete(
eventId = AN_EVENT_ID,
data = aMediaViewerPageData(
mediaSource = MediaSource(aUrl)
@ -506,9 +506,9 @@ class MediaViewerPresenterTest {
)
)
val withBottomSheetState = awaitItem()
assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDeleteConfirmationState::class.java)
assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.DeleteConfirmation::class.java)
updatedState.eventSink(
MediaViewerEvents.Delete(
MediaViewerEvent.Delete(
eventId = AN_EVENT_ID,
)
)
@ -551,7 +551,7 @@ class MediaViewerPresenterTest {
)
val updatedState = awaitItem()
updatedState.eventSink(
MediaViewerEvents.OnNavigateTo(1)
MediaViewerEvent.OnNavigateTo(1)
)
val finalState = awaitItem()
assertThat(finalState.currentIndex).isEqualTo(1)
@ -606,7 +606,7 @@ class MediaViewerPresenterTest {
val updatedState = awaitItem()
// User navigate to the last item (forward loading indicator)
updatedState.eventSink(
MediaViewerEvents.OnNavigateTo(2)
MediaViewerEvent.OnNavigateTo(2)
)
// data source claims that there is no more items to load forward
mediaGalleryDataSource.emitGroupedMediaItems(
@ -680,7 +680,7 @@ class MediaViewerPresenterTest {
val updatedState = awaitItem()
// User navigate to the first item (backward loading indicator)
updatedState.eventSink(
MediaViewerEvents.OnNavigateTo(0)
MediaViewerEvent.OnNavigateTo(0)
)
// data source claims that there is no more items to load backward
mediaGalleryDataSource.emitGroupedMediaItems(
@ -728,7 +728,7 @@ class MediaViewerPresenterTest {
val updatedState = awaitItem()
// User navigate to the media
updatedState.eventSink(
MediaViewerEvents.OnNavigateTo(1)
MediaViewerEvent.OnNavigateTo(1)
)
skipItems(1)
// data source claims that there is no more items to load at all
@ -771,7 +771,7 @@ class MediaViewerPresenterTest {
)
val updatedState = awaitItem()
updatedState.eventSink(
MediaViewerEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS)
MediaViewerEvent.LoadMore(Timeline.PaginationDirection.BACKWARDS)
)
loadMoreLambda.assertions().isCalledOnce().with(value(Timeline.PaginationDirection.BACKWARDS))
}
@ -796,10 +796,10 @@ class MediaViewerPresenterTest {
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(MediaViewerEvents.OpenInfo(aMediaViewerPageData()))
initialState.eventSink(MediaViewerEvent.OpenInfo(aMediaViewerPageData()))
val withBottomSheetState = awaitItem()
assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDetailsBottomSheetState::class.java)
initialState.eventSink(MediaViewerEvents.ViewInTimeline(AN_EVENT_ID))
assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.Details::class.java)
initialState.eventSink(MediaViewerEvent.ViewInTimeline(AN_EVENT_ID))
val finalState = awaitItem()
assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
onViewInTimelineClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
@ -825,10 +825,10 @@ class MediaViewerPresenterTest {
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(MediaViewerEvents.OpenInfo(aMediaViewerPageData()))
initialState.eventSink(MediaViewerEvent.OpenInfo(aMediaViewerPageData()))
val withBottomSheetState = awaitItem()
assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDetailsBottomSheetState::class.java)
initialState.eventSink(MediaViewerEvents.Forward(AN_EVENT_ID))
assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.Details::class.java)
initialState.eventSink(MediaViewerEvent.Forward(AN_EVENT_ID))
val finalState = awaitItem()
assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
onForwardClickLambda.assertions().isCalledOnce()
@ -856,10 +856,10 @@ class MediaViewerPresenterTest {
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(MediaViewerEvents.OpenInfo(aMediaViewerPageData()))
initialState.eventSink(MediaViewerEvent.OpenInfo(aMediaViewerPageData()))
val withBottomSheetState = awaitItem()
assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDetailsBottomSheetState::class.java)
initialState.eventSink(MediaViewerEvents.Forward(AN_EVENT_ID))
assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.Details::class.java)
initialState.eventSink(MediaViewerEvent.Forward(AN_EVENT_ID))
val finalState = awaitItem()
assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
onForwardClickLambda.assertions().isCalledOnce()

View file

@ -10,6 +10,7 @@ package io.element.android.libraries.mediaviewer.impl.viewer
import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.annotation.StringRes
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
@ -19,7 +20,7 @@ import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeDown
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState
import io.element.android.libraries.mediaviewer.impl.details.aMediaBottomSheetStateDetails
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
@ -43,7 +44,7 @@ class MediaViewerViewTest {
@Test
fun `clicking on back invokes expected callback`() {
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
val eventsRecorder = EventsRecorder<MediaViewerEvent>()
val state = aMediaViewerState(
eventSink = eventsRecorder
)
@ -56,42 +57,54 @@ class MediaViewerViewTest {
}
eventsRecorder.assertList(
listOf(
MediaViewerEvents.OnNavigateTo(0),
MediaViewerEvents.LoadMedia(state.listData.first() as MediaViewerPageData.MediaViewerData),
MediaViewerEvent.OnNavigateTo(0),
MediaViewerEvent.LoadMedia(state.listData.first() as MediaViewerPageData.MediaViewerData),
)
)
}
@Test
fun `clicking on open emit expected Event`() {
val data = aMediaViewerPageData(
downloadedMedia = AsyncData.Success(aLocalMedia(uri = mockMediaUrl)),
)
testMenuAction(
data,
CommonStrings.action_open_with,
MediaViewerEvents.OpenWith(data),
)
}
@Test
fun `clicking on info emit expected Event`() {
fun `clicking on info emits expected Event`() {
val data = aMediaViewerPageData(
downloadedMedia = AsyncData.Success(aLocalMedia(uri = mockMediaUrl)),
)
testMenuAction(
data,
CommonStrings.a11y_view_details,
MediaViewerEvents.OpenInfo(data),
MediaViewerEvent.OpenInfo(data),
)
}
@Test
fun `clicking on top action share emits expected Event`() {
val data = aMediaViewerPageData(
downloadedMedia = AsyncData.Success(aLocalMedia(uri = mockMediaUrl)),
)
testMenuAction(
data,
CommonStrings.action_share,
MediaViewerEvent.Share(data),
)
}
@Test
fun `clicking on top action download emits expected Event`() {
val data = aMediaViewerPageData(
downloadedMedia = AsyncData.Success(aLocalMedia(uri = mockMediaUrl)),
)
testMenuAction(
data,
CommonStrings.action_download,
MediaViewerEvent.SaveOnDisk(data),
)
}
private fun testMenuAction(
data: MediaViewerPageData.MediaViewerData,
contentDescriptionRes: Int,
expectedEvent: MediaViewerEvents,
@StringRes contentDescriptionRes: Int,
expectedEvent: MediaViewerEvent,
) {
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
val eventsRecorder = EventsRecorder<MediaViewerEvent>()
rule.setMediaViewerView(
aMediaViewerState(
listData = listOf(data),
@ -102,8 +115,8 @@ class MediaViewerViewTest {
rule.onNodeWithContentDescription(contentDescription).performClick()
eventsRecorder.assertList(
listOf(
MediaViewerEvents.OnNavigateTo(0),
MediaViewerEvents.LoadMedia(data),
MediaViewerEvent.OnNavigateTo(0),
MediaViewerEvent.LoadMedia(data),
expectedEvent,
)
)
@ -111,44 +124,55 @@ class MediaViewerViewTest {
@Test
@Config(qualifiers = "h1024dp")
fun `clicking on save emit expected Event`() {
fun `clicking on download emits expected Event`() {
val data = aMediaViewerPageData()
testBottomSheetAction(
data,
CommonStrings.action_save,
MediaViewerEvents.SaveOnDisk(data),
CommonStrings.action_download,
MediaViewerEvent.SaveOnDisk(data),
)
}
@Test
@Config(qualifiers = "h1024dp")
fun `clicking on share emit expected Event`() {
fun `clicking on share emits expected Event`() {
val data = aMediaViewerPageData()
testBottomSheetAction(
data,
CommonStrings.action_share,
MediaViewerEvents.Share(data),
MediaViewerEvent.Share(data),
)
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on open in emits expected Event`() {
val data = aMediaViewerPageData()
testBottomSheetAction(
data,
CommonStrings.action_open_with,
MediaViewerEvent.OpenWith(data),
)
}
private fun testBottomSheetAction(
data: MediaViewerPageData.MediaViewerData,
contentDescriptionRes: Int,
expectedEvent: MediaViewerEvents,
@StringRes textRes: Int,
expectedEvent: MediaViewerEvent,
) {
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
val eventsRecorder = EventsRecorder<MediaViewerEvent>()
rule.setMediaViewerView(
aMediaViewerState(
listData = listOf(data),
mediaBottomSheetState = aMediaDetailsBottomSheetState(),
mediaBottomSheetState = aMediaBottomSheetStateDetails(),
eventSink = eventsRecorder
),
)
rule.clickOn(contentDescriptionRes)
rule.clickOn(textRes)
eventsRecorder.assertList(
listOf(
MediaViewerEvents.OnNavigateTo(0),
MediaViewerEvents.LoadMedia(data),
MediaViewerEvent.OnNavigateTo(0),
MediaViewerEvent.LoadMedia(data),
expectedEvent,
)
)
@ -156,7 +180,7 @@ class MediaViewerViewTest {
@Test
fun `clicking on image hides the overlay`() {
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
val eventsRecorder = EventsRecorder<MediaViewerEvent>()
val state = aMediaViewerState(
eventSink = eventsRecorder
)
@ -164,7 +188,7 @@ class MediaViewerViewTest {
state = state,
)
// Ensure that the action are visible
val contentDescription = rule.activity.getString(CommonStrings.action_open_with)
val contentDescription = rule.activity.getString(CommonStrings.action_share)
rule.onNodeWithContentDescription(contentDescription)
.assertExists()
.assertHasClickAction()
@ -176,15 +200,15 @@ class MediaViewerViewTest {
.assertDoesNotExist()
eventsRecorder.assertList(
listOf(
MediaViewerEvents.OnNavigateTo(0),
MediaViewerEvents.LoadMedia(state.listData.first() as MediaViewerPageData.MediaViewerData),
MediaViewerEvent.OnNavigateTo(0),
MediaViewerEvent.LoadMedia(state.listData.first() as MediaViewerPageData.MediaViewerData),
)
)
}
@Test
fun `clicking swipe on the image invokes the expected callback`() {
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
val eventsRecorder = EventsRecorder<MediaViewerEvent>()
val state = aMediaViewerState(
eventSink = eventsRecorder
)
@ -199,15 +223,15 @@ class MediaViewerViewTest {
}
eventsRecorder.assertList(
listOf(
MediaViewerEvents.OnNavigateTo(0),
MediaViewerEvents.LoadMedia(state.listData.first() as MediaViewerPageData.MediaViewerData),
MediaViewerEvent.OnNavigateTo(0),
MediaViewerEvent.LoadMedia(state.listData.first() as MediaViewerPageData.MediaViewerData),
)
)
}
@Test
fun `error case, click on retry emits the expected Event`() {
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
val eventsRecorder = EventsRecorder<MediaViewerEvent>()
val data = aMediaViewerPageData(
downloadedMedia = AsyncData.Failure(IllegalStateException("error")),
)
@ -220,16 +244,16 @@ class MediaViewerViewTest {
rule.clickOn(CommonStrings.action_retry)
eventsRecorder.assertList(
listOf(
MediaViewerEvents.OnNavigateTo(0),
MediaViewerEvents.LoadMedia(data),
MediaViewerEvents.LoadMedia(data),
MediaViewerEvent.OnNavigateTo(0),
MediaViewerEvent.LoadMedia(data),
MediaViewerEvent.LoadMedia(data),
)
)
}
@Test
fun `error case, click on cancel emits the expected Event`() {
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
val eventsRecorder = EventsRecorder<MediaViewerEvent>()
val data = aMediaViewerPageData(
downloadedMedia = AsyncData.Failure(IllegalStateException("error")),
)
@ -242,9 +266,9 @@ class MediaViewerViewTest {
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertList(
listOf(
MediaViewerEvents.OnNavigateTo(0),
MediaViewerEvents.LoadMedia(data),
MediaViewerEvents.ClearLoadingError(data)
MediaViewerEvent.OnNavigateTo(0),
MediaViewerEvent.LoadMedia(data),
MediaViewerEvent.ClearLoadingError(data)
)
)
}

View file

@ -11,37 +11,29 @@ package io.element.android.libraries.mediaviewer.test
import androidx.compose.runtime.Composable
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
class FakeLocalMediaActions : LocalMediaActions {
var shouldFail = false
class FakeLocalMediaActions(
val configureResult: () -> Unit = { },
val saveOnDiskResult: (LocalMedia) -> Result<Unit> = { lambdaError() },
val shareResult: (LocalMedia) -> Result<Unit> = { lambdaError() },
val openResult: (LocalMedia) -> Result<Unit> = { lambdaError() },
) : LocalMediaActions {
@Composable
override fun Configure() {
// NOOP
configureResult()
}
override suspend fun saveOnDisk(localMedia: LocalMedia): Result<Unit> = simulateLongTask {
if (shouldFail) {
Result.failure(RuntimeException())
} else {
Result.success(Unit)
}
saveOnDiskResult(localMedia)
}
override suspend fun share(localMedia: LocalMedia): Result<Unit> = simulateLongTask {
if (shouldFail) {
Result.failure(RuntimeException())
} else {
Result.success(Unit)
}
shareResult(localMedia)
}
override suspend fun open(localMedia: LocalMedia): Result<Unit> = simulateLongTask {
if (shouldFail) {
Result.failure(RuntimeException())
} else {
Result.success(Unit)
}
openResult(localMedia)
}
}

View file

@ -0,0 +1,12 @@
/*
* 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.ui.strings
object Strings {
const val NICE_SEPARATOR = ""
}

View file

@ -96,6 +96,7 @@
<string name="action_discard">"Discard"</string>
<string name="action_dismiss">"Dismiss"</string>
<string name="action_done">"Done"</string>
<string name="action_download">"Download"</string>
<string name="action_edit">"Edit"</string>
<string name="action_edit_caption">"Edit caption"</string>
<string name="action_edit_poll">"Edit poll"</string>