From 3e1b1c29d1c579aba19f7b9ad49813f669616a49 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 9 Dec 2024 16:45:46 +0100 Subject: [PATCH 01/31] Media Gallery --- .../messages/impl/MessagesFlowNode.kt | 24 +- .../roomdetails/impl/RoomDetailsFlowNode.kt | 37 +- .../roomdetails/impl/RoomDetailsNode.kt | 6 + .../roomdetails/impl/RoomDetailsPresenter.kt | 6 + .../roomdetails/impl/RoomDetailsState.kt | 1 + .../impl/RoomDetailsStateProvider.kt | 2 + .../roomdetails/impl/RoomDetailsView.kt | 21 +- .../userprofile/impl/UserProfileFlowNode.kt | 5 + .../core/extensions/BasicExtensions.kt | 8 + .../libraries/core/preview/PreviewUtil.kt | 16 + .../components/avatar/AvatarSize.kt | 2 + .../libraries/featureflag/api/FeatureFlags.kt | 7 + .../libraries/matrix/api/room/MatrixRoom.kt | 5 + .../libraries/matrix/api/timeline/Timeline.kt | 3 +- .../matrix/impl/room/RustMatrixRoom.kt | 21 + .../matrix/impl/timeline/RustTimeline.kt | 42 +- .../matrix/test/room/FakeMatrixRoom.kt | 5 + .../mediaviewer/api/MediaGalleryEntryPoint.kt | 28 ++ .../libraries/mediaviewer/api/MediaInfo.kt | 21 +- .../mediaviewer/api/MediaViewerEntryPoint.kt | 4 + libraries/mediaviewer/impl/build.gradle.kts | 6 + .../impl/DefaultMediaGalleryEntryPoint.kt | 36 ++ .../impl/DefaultMediaViewerEntryPoint.kt | 5 + .../impl/details/MediaBottomSheetState.kt | 29 ++ .../MediaDeleteConfirmationBottomSheet.kt | 175 +++++++ .../impl/details/MediaDetailsBottomSheet.kt | 210 +++++++++ .../impl/gallery/EventItemFactory.kt | 194 ++++++++ .../impl/gallery/MediaGalleryEvents.kt | 31 ++ .../impl/gallery/MediaGalleryNavigator.kt | 14 + .../impl/gallery/MediaGalleryNode.kt | 67 +++ .../impl/gallery/MediaGalleryPresenter.kt | 264 +++++++++++ .../impl/gallery/MediaGalleryState.kt | 29 ++ .../impl/gallery/MediaGalleryStateProvider.kt | 77 ++++ .../gallery/MediaGalleryTimelineProvider.kt | 113 +++++ .../impl/gallery/MediaGalleryView.kt | 436 ++++++++++++++++++ .../mediaviewer/impl/gallery/MediaItem.kt | 102 ++++ .../impl/gallery/MediaItemsPostProcessor.kt | 71 +++ .../impl/gallery/TimelineMediaItemsFactory.kt | 119 +++++ .../impl/gallery/VirtualItemFactory.kt | 42 ++ .../TimelineMediaItemsCacheInvalidator.kt | 53 +++ .../impl/gallery/root/MediaGalleryRootNode.kt | 139 ++++++ .../impl/gallery/ui/DateItemView.kt | 45 ++ .../impl/gallery/ui/FileItemView.kt | 183 ++++++++ .../impl/gallery/ui/ImageItemView.kt | 74 +++ .../ui/MediaItemDateSeparatorProvider.kt | 30 ++ .../impl/gallery/ui/MediaItemFileProvider.kt | 45 ++ .../impl/gallery/ui/MediaItemImageProvider.kt | 30 ++ .../impl/gallery/ui/MediaItemVideoProvider.kt | 39 ++ .../impl/gallery/ui/VideoItemView.kt | 116 +++++ .../impl/local/AndroidLocalMediaFactory.kt | 9 + .../impl/viewer/MediaViewerEvents.kt | 4 + .../impl/viewer/MediaViewerNavigator.kt | 15 + .../impl/viewer/MediaViewerNode.kt | 21 +- .../impl/viewer/MediaViewerPresenter.kt | 41 +- .../impl/viewer/MediaViewerState.kt | 4 + .../impl/viewer/MediaViewerStateProvider.kt | 37 +- .../impl/viewer/MediaViewerView.kt | 61 ++- .../impl/src/main/res/values/localazy.xml | 16 + .../impl/gallery/FakeEventItemFactory.kt | 16 + .../impl/gallery/FakeMediaGalleryNavigator.kt | 19 + .../gallery/FakeMediaItemsPostProcessor.kt | 17 + .../gallery/FakeTimelineMediaItemsFactory.kt | 31 ++ .../impl/gallery/FakeVirtualItemFactory.kt | 16 + .../impl/gallery/MediaGalleryPresenterTest.kt | 261 +++++++++++ .../local/AndroidLocalMediaFactoryTest.kt | 2 + .../impl/viewer/FakeMediaViewerNavigator.kt | 24 + .../impl/viewer/MediaViewerPresenterTest.kt | 237 +++++++++- .../mediaviewer/test/FakeLocalMediaFactory.kt | 2 + tools/localazy/config.json | 7 + 69 files changed, 3822 insertions(+), 56 deletions(-) create mode 100644 libraries/core/src/main/kotlin/io/element/android/libraries/core/preview/PreviewUtil.kt create mode 100644 libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaGalleryEntryPoint.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaGalleryEntryPoint.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaBottomSheetState.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheet.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheet.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryEvents.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNavigator.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNode.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryState.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryTimelineProvider.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItem.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaItemsFactory.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/VirtualItemFactory.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/diff/TimelineMediaItemsCacheInvalidator.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryRootNode.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/DateItemView.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/FileItemView.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/ImageItemView.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemDateSeparatorProvider.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemFileProvider.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemImageProvider.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVideoProvider.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNavigator.kt create mode 100644 libraries/mediaviewer/impl/src/main/res/values/localazy.xml create mode 100644 libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeEventItemFactory.kt create mode 100644 libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaGalleryNavigator.kt create mode 100644 libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaItemsPostProcessor.kt create mode 100644 libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeTimelineMediaItemsFactory.kt create mode 100644 libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeVirtualItemFactory.kt create mode 100644 libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt create mode 100644 libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/FakeMediaViewerNavigator.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 27b219fc1c..3b5fb67540 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -117,6 +117,7 @@ class MessagesFlowNode @AssistedInject constructor( @Parcelize data class MediaViewer( + val eventId: EventId?, val mediaInfo: MediaInfo, val mediaSource: MediaSource, val thumbnailSource: MediaSource?, @@ -241,9 +242,11 @@ class MessagesFlowNode @AssistedInject constructor( } is NavTarget.MediaViewer -> { val params = MediaViewerEntryPoint.Params( + eventId = navTarget.eventId, mediaInfo = navTarget.mediaInfo, mediaSource = navTarget.mediaSource, thumbnailSource = navTarget.thumbnailSource, + canShowInfo = true, canDownload = true, canShare = true, ) @@ -251,6 +254,10 @@ class MessagesFlowNode @AssistedInject constructor( override fun onDone() { overlay.hide() } + + override fun onViewInTimeline(eventId: EventId) { + viewInTimeline(eventId) + } } mediaViewerEntryPoint.nodeBuilder(this, buildContext) .params(params) @@ -311,11 +318,7 @@ class MessagesFlowNode @AssistedInject constructor( } override fun onViewInTimelineClick(eventId: EventId) { - val permalinkData = PermalinkData.RoomLink( - roomIdOrAlias = room.roomId.toRoomIdOrAlias(), - eventId = eventId, - ) - callbacks.forEach { it.onPermalinkClick(permalinkData, pushToBackstack = false) } + viewInTimeline(eventId) } override fun onRoomPermalinkClick(data: PermalinkData.RoomLink) { @@ -341,6 +344,14 @@ class MessagesFlowNode @AssistedInject constructor( } } + private fun viewInTimeline(eventId: EventId) { + val permalinkData = PermalinkData.RoomLink( + roomIdOrAlias = room.roomId.toRoomIdOrAlias(), + eventId = eventId, + ) + callbacks.forEach { it.onPermalinkClick(permalinkData, pushToBackstack = false) } + } + private fun processEventClick(event: TimelineItem.Event): Boolean { val navTarget = when (event.content) { is TimelineItemImageContent -> { @@ -415,13 +426,16 @@ class MessagesFlowNode @AssistedInject constructor( thumbnailSource: MediaSource?, ): NavTarget { return NavTarget.MediaViewer( + eventId = event.eventId, mediaInfo = MediaInfo( filename = content.filename, caption = content.caption, mimeType = content.mimeType, formattedFileSize = content.formattedFileSize, fileExtension = content.fileExtension, + senderId = event.senderId, senderName = event.safeSenderName, + senderAvatar = event.senderAvatar.url, dateSent = event.sentTime, ), mediaSource = mediaSource, diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt index eb13a0ba5b..cf1443fc8b 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt @@ -15,6 +15,7 @@ import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.plugins import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.pop import com.bumble.appyx.navmodel.backstack.operation.push import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -39,10 +40,13 @@ import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.overlay.operation.hide import io.element.android.libraries.architecture.overlay.operation.show import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analyticsproviders.api.trackers.captureInteraction @@ -59,6 +63,7 @@ class RoomDetailsFlowNode @AssistedInject constructor( private val messagesEntryPoint: MessagesEntryPoint, private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint, private val mediaViewerEntryPoint: MediaViewerEntryPoint, + private val mediaGalleryEntryPoint: MediaGalleryEntryPoint, ) : BaseFlowNode( backstack = BackStack( initialElement = plugins.filterIsInstance().first().initialElement.toNavTarget(), @@ -98,6 +103,9 @@ class RoomDetailsFlowNode @AssistedInject constructor( @Parcelize data object PollHistory : NavTarget + @Parcelize + data object MediaGallery : NavTarget + @Parcelize data object AdminSettings : NavTarget @@ -136,6 +144,10 @@ class RoomDetailsFlowNode @AssistedInject constructor( backstack.push(NavTarget.PollHistory) } + override fun openMediaGallery() { + backstack.push(NavTarget.MediaGallery) + } + override fun openAdminSettings() { backstack.push(NavTarget.AdminSettings) } @@ -213,6 +225,10 @@ class RoomDetailsFlowNode @AssistedInject constructor( override fun onDone() { overlay.hide() } + + override fun onViewInTimeline(eventId: EventId) { + // Cannot happen + } } mediaViewerEntryPoint.nodeBuilder(this, buildContext) .avatar( @@ -222,10 +238,29 @@ class RoomDetailsFlowNode @AssistedInject constructor( .callback(callback) .build() } - is NavTarget.PollHistory -> { pollHistoryEntryPoint.createNode(this, buildContext) } + is NavTarget.MediaGallery -> { + val callback = object : MediaGalleryEntryPoint.Callback { + override fun onDone() { + backstack.pop() + } + + override fun onViewInTimeline(eventId: EventId) { + val permalinkData = PermalinkData.RoomLink( + roomIdOrAlias = room.roomId.toRoomIdOrAlias(), + eventId = eventId, + ) + plugins().forEach { + it.onPermalinkClick(permalinkData, pushToBackstack = false) + } + } + } + mediaGalleryEntryPoint.nodeBuilder(this, buildContext) + .callback(callback) + .build() + } is NavTarget.AdminSettings -> { createNode(buildContext) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt index a56a028808..3b3d11fbd9 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt @@ -45,6 +45,7 @@ class RoomDetailsNode @AssistedInject constructor( fun openRoomNotificationSettings() fun openAvatarPreview(name: String, url: String) fun openPollHistory() + fun openMediaGallery() fun openAdminSettings() fun openPinnedMessagesList() fun openKnockRequestsList() @@ -77,6 +78,10 @@ class RoomDetailsNode @AssistedInject constructor( callbacks.forEach { it.openPollHistory() } } + private fun openMediaGallery() { + callbacks.forEach { it.openMediaGallery() } + } + private fun onJoinCall() { callbacks.forEach { it.onJoinCall() } } @@ -143,6 +148,7 @@ class RoomDetailsNode @AssistedInject constructor( invitePeople = ::invitePeople, openAvatarPreview = ::openAvatarPreview, openPollHistory = ::openPollHistory, + openMediaGallery = ::openMediaGallery, openAdminSettings = this::openAdminSettings, onJoinCallClick = ::onJoinCall, onPinnedMessagesClick = ::openPinnedMessages, diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt index 46588ae5fe..d55f4e4f7d 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import im.vector.app.features.analytics.plan.Interaction import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomState @@ -79,6 +80,10 @@ class RoomDetailsPresenter @Inject constructor( val isPublic by remember { derivedStateOf { roomInfo?.isPublic.orFalse() } } val canShowPinnedMessages = isPinnedMessagesFeatureEnabled() + var canShowMediaGallery by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + canShowMediaGallery = featureFlagService.isFeatureEnabled(FeatureFlags.MediaGallery) + } val pinnedMessagesCount by remember { derivedStateOf { roomInfo?.pinnedEventIds?.size } } LaunchedEffect(Unit) { @@ -162,6 +167,7 @@ class RoomDetailsPresenter @Inject constructor( isPublic = isPublic, heroes = roomInfo?.heroes.orEmpty().toPersistentList(), canShowPinnedMessages = canShowPinnedMessages, + canShowMediaGallery = canShowMediaGallery, pinnedMessagesCount = pinnedMessagesCount, canShowKnockRequests = canShowKnockRequests, knockRequestsCount = knockRequestsCount, diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt index 7f15c846f9..85b5340959 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt @@ -40,6 +40,7 @@ data class RoomDetailsState( val isPublic: Boolean, val heroes: ImmutableList, val canShowPinnedMessages: Boolean, + val canShowMediaGallery: Boolean, val pinnedMessagesCount: Int?, val canShowKnockRequests: Boolean, val knockRequestsCount: Int?, diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt index dcf5bc3054..b3a4c0e7ee 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt @@ -101,6 +101,7 @@ fun aRoomDetailsState( isPublic: Boolean = true, heroes: List = emptyList(), canShowPinnedMessages: Boolean = true, + canShowMediaGallery: Boolean = true, pinnedMessagesCount: Int? = null, canShowKnockRequests: Boolean = false, knockRequestsCount: Int? = null, @@ -126,6 +127,7 @@ fun aRoomDetailsState( isPublic = isPublic, heroes = heroes.toPersistentList(), canShowPinnedMessages = canShowPinnedMessages, + canShowMediaGallery = canShowMediaGallery, pinnedMessagesCount = pinnedMessagesCount, canShowKnockRequests = canShowKnockRequests, knockRequestsCount = knockRequestsCount, diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt index d73b8e2626..5e65ce9336 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt @@ -101,6 +101,7 @@ fun RoomDetailsView( invitePeople: () -> Unit, openAvatarPreview: (name: String, url: String) -> Unit, openPollHistory: () -> Unit, + openMediaGallery: () -> Unit, openAdminSettings: () -> Unit, onJoinCallClick: () -> Unit, onPinnedMessagesClick: () -> Unit, @@ -219,7 +220,11 @@ fun RoomDetailsView( PollsSection( openPollHistory = openPollHistory ) - + if (state.canShowMediaGallery) { + MediaGallerySection( + onClick = openMediaGallery + ) + } if (state.isEncrypted) { SecuritySection() } @@ -576,6 +581,19 @@ private fun PollsSection( } } +@Composable +private fun MediaGallerySection( + onClick: () -> Unit, +) { + PreferenceCategory { + ListItem( + headlineContent = { Text(stringResource(R.string.screen_room_details_media_gallery_title)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Image())), + onClick = onClick, + ) + } +} + @Composable private fun SecuritySection() { PreferenceCategory(title = stringResource(R.string.screen_room_details_security_title)) { @@ -631,6 +649,7 @@ private fun ContentToPreview(state: RoomDetailsState) { invitePeople = {}, openAvatarPreview = { _, _ -> }, openPollHistory = {}, + openMediaGallery = {}, openAdminSettings = {}, onJoinCallClick = {}, onPinnedMessagesClick = {}, diff --git a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt index ce0d4a07f0..d795f60715 100644 --- a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt +++ b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt @@ -30,6 +30,7 @@ import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint @@ -82,6 +83,10 @@ class UserProfileFlowNode @AssistedInject constructor( override fun onDone() { backstack.pop() } + + override fun onViewInTimeline(eventId: EventId) { + // Cannot happen + } } mediaViewerEntryPoint.nodeBuilder(this, buildContext) .avatar( diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt index 8287e2d19d..f4dd61bf6e 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt @@ -61,3 +61,11 @@ fun String.replacePrefix(oldPrefix: String, newPrefix: String): String { this } } + +/** + * Surround with brackets. + */ +fun String.withBrackets(prefix: String = "(", suffix: String = ")"): String { + return "$prefix$this$suffix" +} + diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/preview/PreviewUtil.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/preview/PreviewUtil.kt new file mode 100644 index 0000000000..e4b20fd42b --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/preview/PreviewUtil.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.core.preview + +val loremIpsum = """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut la + bore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate v + elit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proide + nt, sunt in culpa qui officia deserunt mollit anim id est laborum. + """.trimIndent() diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt index 3f7d087f41..f3c9fb317a 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt @@ -57,4 +57,6 @@ enum class AvatarSize(val dp: Dp) { KnockRequestItem(52.dp), KnockRequestBanner(32.dp), + + MediaSender(32.dp), } diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index 7d21ed1138..9add32499f 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -154,4 +154,11 @@ enum class FeatureFlags( defaultValue = { true }, isFinished = false, ), + MediaGallery( + key = "feature.media_gallery", + title = "Allow user to open the media gallery", + description = null, + defaultValue = { buildMeta -> buildMeta.buildType != BuildType.RELEASE }, + isFinished = false, + ), } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt index 989c301e92..840308af23 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt @@ -107,6 +107,11 @@ interface MatrixRoom : Closeable { */ suspend fun pinnedEventsTimeline(): Result + /** + * Create a new timeline for the media events of the room. + */ + suspend fun mediaTimeline(): Result + fun destroy() suspend fun subscribeToSync() diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt index 00f7a9a17c..29f8997ace 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt @@ -42,7 +42,8 @@ interface Timeline : AutoCloseable { enum class Mode { LIVE, FOCUSED_ON_EVENT, - PINNED_EVENTS + PINNED_EVENTS, + MEDIA, } val membershipChangeEventReceived: Flow diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index c3057298fa..55f639e266 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -78,6 +78,7 @@ import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener import org.matrix.rustcomponents.sdk.RoomInfo import org.matrix.rustcomponents.sdk.RoomInfoListener import org.matrix.rustcomponents.sdk.RoomListItem +import org.matrix.rustcomponents.sdk.RoomMessageEventMessageType import org.matrix.rustcomponents.sdk.TypingNotificationsListener import org.matrix.rustcomponents.sdk.UserPowerLevelUpdate import org.matrix.rustcomponents.sdk.WidgetCapabilities @@ -223,6 +224,26 @@ class RustMatrixRoom( } } + override suspend fun mediaTimeline(): Result { + return runCatching { + innerRoom.messageFilteredTimeline( + internalIdPrefix = "MediaGallery_", + allowedMessageTypes = listOf( + RoomMessageEventMessageType.FILE, + RoomMessageEventMessageType.IMAGE, + RoomMessageEventMessageType.VIDEO, + RoomMessageEventMessageType.AUDIO, + ) + ).let { inner -> + createTimeline(inner, mode = Timeline.Mode.MEDIA) + } + }.onFailure { + if (it is CancellationException) { + throw it + } + } + } + override fun destroy() { roomCoroutineScope.cancel() liveTimeline.close() diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt index 200f1289b4..1a9da807a0 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt @@ -64,6 +64,8 @@ import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.EditedContent import org.matrix.rustcomponents.sdk.FormattedBody @@ -170,26 +172,36 @@ class RustTimeline( } } + private val backwardsPaginationMutex = Mutex() + private val forwardsPaginationMutex = Mutex() + + private fun getPaginationMutex(direction: Timeline.PaginationDirection) = when (direction) { + Timeline.PaginationDirection.BACKWARDS -> backwardsPaginationMutex + Timeline.PaginationDirection.FORWARDS -> forwardsPaginationMutex + } + // Use NonCancellable to avoid breaking the timeline when the coroutine is cancelled. override suspend fun paginate(direction: Timeline.PaginationDirection): Result = withContext(NonCancellable) { withContext(dispatcher) { initLatch.await() - runCatching { - if (!canPaginate(direction)) throw TimelineException.CannotPaginate - updatePaginationStatus(direction) { it.copy(isPaginating = true) } - when (direction) { - Timeline.PaginationDirection.BACKWARDS -> inner.paginateBackwards(PAGINATION_SIZE.toUShort()) - Timeline.PaginationDirection.FORWARDS -> inner.focusedPaginateForwards(PAGINATION_SIZE.toUShort()) + getPaginationMutex(direction).withLock { + runCatching { + if (!canPaginate(direction)) throw TimelineException.CannotPaginate + updatePaginationStatus(direction) { it.copy(isPaginating = true) } + when (direction) { + Timeline.PaginationDirection.BACKWARDS -> inner.paginateBackwards(PAGINATION_SIZE.toUShort()) + Timeline.PaginationDirection.FORWARDS -> inner.focusedPaginateForwards(PAGINATION_SIZE.toUShort()) + } + }.onFailure { error -> + updatePaginationStatus(direction) { it.copy(isPaginating = false) } + if (error is TimelineException.CannotPaginate) { + Timber.d("Can't paginate $direction on room ${matrixRoom.roomId} with paginationStatus: ${backPaginationStatus.value}") + } else { + Timber.e(error, "Error paginating $direction on room ${matrixRoom.roomId}") + } + }.onSuccess { hasReachedEnd -> + updatePaginationStatus(direction) { it.copy(isPaginating = false, hasMoreToLoad = !hasReachedEnd) } } - }.onFailure { error -> - updatePaginationStatus(direction) { it.copy(isPaginating = false) } - if (error is TimelineException.CannotPaginate) { - Timber.d("Can't paginate $direction on room ${matrixRoom.roomId} with paginationStatus: ${backPaginationStatus.value}") - } else { - Timber.e(error, "Error paginating $direction on room ${matrixRoom.roomId}") - } - }.onSuccess { hasReachedEnd -> - updatePaginationStatus(direction) { it.copy(isPaginating = false, hasMoreToLoad = !hasReachedEnd) } } } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index 9974e36746..b357c51fee 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -133,6 +133,7 @@ class FakeMatrixRoom( private val getMembersResult: (Int) -> Result> = { lambdaError() }, private val timelineFocusedOnEventResult: (EventId) -> Result = { lambdaError() }, private val pinnedEventsTimelineResult: () -> Result = { lambdaError() }, + private val mediaTimelineResult: () -> Result = { lambdaError() }, private val setSendQueueEnabledLambda: (Boolean) -> Unit = { _: Boolean -> }, private val saveComposerDraftLambda: (ComposerDraft) -> Result = { _: ComposerDraft -> Result.success(Unit) }, private val loadComposerDraftLambda: () -> Result = { Result.success(null) }, @@ -203,6 +204,10 @@ class FakeMatrixRoom( pinnedEventsTimelineResult() } + override suspend fun mediaTimeline(): Result = simulateLongTask { + mediaTimelineResult() + } + override suspend fun subscribeToSync() { subscribeToSyncLambda() } diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaGalleryEntryPoint.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaGalleryEntryPoint.kt new file mode 100644 index 0000000000..e8b438b642 --- /dev/null +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaGalleryEntryPoint.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.api + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import io.element.android.libraries.architecture.FeatureEntryPoint +import io.element.android.libraries.matrix.api.core.EventId + +interface MediaGalleryEntryPoint : FeatureEntryPoint { + fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder + + interface NodeBuilder { + fun callback(callback: Callback): NodeBuilder + fun build(): Node + } + + interface Callback : Plugin { + fun onDone() + fun onViewInTimeline(eventId: EventId) + } +} diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt index 5c317b1d6a..17a1052954 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt @@ -9,6 +9,7 @@ package io.element.android.libraries.mediaviewer.api import android.os.Parcelable import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.core.UserId import kotlinx.parcelize.Parcelize @Parcelize @@ -18,11 +19,14 @@ data class MediaInfo( val mimeType: String, val formattedFileSize: String, val fileExtension: String, + val senderId: UserId?, val senderName: String?, + val senderAvatar: String?, val dateSent: String?, ) : Parcelable fun anImageMediaInfo( + senderId: UserId? = UserId("@alice:server.org"), caption: String? = null, senderName: String? = null, dateSent: String? = null, @@ -32,7 +36,9 @@ fun anImageMediaInfo( mimeType = MimeTypes.Jpeg, formattedFileSize = "4MB", fileExtension = "jpg", + senderId = senderId, senderName = senderName, + senderAvatar = null, dateSent = dateSent, ) @@ -46,24 +52,31 @@ fun aVideoMediaInfo( mimeType = MimeTypes.Mp4, formattedFileSize = "14MB", fileExtension = "mp4", + senderId = UserId("@alice:server.org"), senderName = senderName, + senderAvatar = null, dateSent = dateSent, ) fun aPdfMediaInfo( + filename: String = "a pdf file.pdf", + caption: String? = null, senderName: String? = null, dateSent: String? = null, ): MediaInfo = MediaInfo( - filename = "a pdf file.pdf", - caption = null, + filename = filename, + caption = caption, mimeType = MimeTypes.Pdf, formattedFileSize = "23MB", fileExtension = "pdf", + senderId = UserId("@alice:server.org"), senderName = senderName, + senderAvatar = null, dateSent = dateSent, ) fun anApkMediaInfo( + senderId: UserId? = UserId("@alice:server.org"), senderName: String? = null, dateSent: String? = null, ): MediaInfo = MediaInfo( @@ -72,7 +85,9 @@ fun anApkMediaInfo( mimeType = MimeTypes.Apk, formattedFileSize = "50MB", fileExtension = "apk", + senderId = senderId, senderName = senderName, + senderAvatar = null, dateSent = dateSent, ) @@ -85,6 +100,8 @@ fun anAudioMediaInfo( mimeType = MimeTypes.Mp3, formattedFileSize = "7MB", fileExtension = "mp3", + senderId = UserId("@alice:server.org"), senderName = senderName, + senderAvatar = null, dateSent = dateSent, ) diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt index fb5ee5dece..3e262c08f2 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt @@ -12,6 +12,7 @@ import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.media.MediaSource interface MediaViewerEntryPoint : FeatureEntryPoint { @@ -26,12 +27,15 @@ interface MediaViewerEntryPoint : FeatureEntryPoint { interface Callback : Plugin { fun onDone() + fun onViewInTimeline(eventId: EventId) } data class Params( + val eventId: EventId?, val mediaInfo: MediaInfo, val mediaSource: MediaSource, val thumbnailSource: MediaSource?, + val canShowInfo: Boolean, val canDownload: Boolean, val canShare: Boolean, ) : NodeInputs diff --git a/libraries/mediaviewer/impl/build.gradle.kts b/libraries/mediaviewer/impl/build.gradle.kts index 5ebc252343..deeffe9e8b 100644 --- a/libraries/mediaviewer/impl/build.gradle.kts +++ b/libraries/mediaviewer/impl/build.gradle.kts @@ -33,15 +33,18 @@ dependencies { implementation(libs.vanniktech.blurhash) implementation(libs.telephoto.flick) + implementation(projects.features.networkmonitor.api) implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.libraries.core) implementation(projects.libraries.dateformatter.api) implementation(projects.libraries.di) implementation(projects.libraries.designsystem) + implementation(projects.libraries.featureflag.api) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) implementation(projects.libraries.uiStrings) + implementation(projects.services.toolbox.api) api(projects.libraries.mediaviewer.api) implementation(projects.libraries.androidutils) @@ -49,8 +52,11 @@ dependencies { implementation(projects.libraries.di) implementation(projects.libraries.matrix.api) + testImplementation(projects.features.networkmonitor.test) + testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.mediaviewer.test) + testImplementation(projects.services.toolbox.test) testImplementation(projects.tests.testutils) testImplementation(libs.test.junit) testImplementation(libs.test.truth) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaGalleryEntryPoint.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaGalleryEntryPoint.kt new file mode 100644 index 0000000000..5d4fd8b297 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaGalleryEntryPoint.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint +import io.element.android.libraries.mediaviewer.impl.gallery.root.MediaGalleryRootNode +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultMediaGalleryEntryPoint @Inject constructor() : MediaGalleryEntryPoint { + override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): MediaGalleryEntryPoint.NodeBuilder { + val plugins = ArrayList() + + return object : MediaGalleryEntryPoint.NodeBuilder { + override fun callback(callback: MediaGalleryEntryPoint.Callback): MediaGalleryEntryPoint.NodeBuilder { + plugins += callback + return this + } + + override fun build(): Node { + return parentNode.createNode(buildContext, plugins) + } + } + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt index 86d7bca722..f9611a7023 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt @@ -14,6 +14,7 @@ import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.architecture.createNode import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.mediaviewer.api.MediaInfo import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint @@ -41,17 +42,21 @@ class DefaultMediaViewerEntryPoint @Inject constructor() : MediaViewerEntryPoint val mimeType = MimeTypes.Images return params( MediaViewerEntryPoint.Params( + eventId = null, mediaInfo = MediaInfo( filename = filename, caption = null, mimeType = mimeType, formattedFileSize = "", fileExtension = "", + senderId = UserId("@dummy:server.org"), senderName = null, + senderAvatar = null, dateSent = null, ), mediaSource = MediaSource(url = avatarUrl), thumbnailSource = null, + canShowInfo = false, canDownload = false, canShare = false, ) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaBottomSheetState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaBottomSheetState.kt new file mode 100644 index 0000000000..c55e3c2295 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaBottomSheetState.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE 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.matrix.api.media.MediaSource +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( + val eventId: EventId?, + val canDelete: Boolean, + val mediaInfo: MediaInfo, + val thumbnailSource: MediaSource?, + ) : MediaBottomSheetState +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheet.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheet.kt new file mode 100644 index 0000000000..9be9d84b6f --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheet.kt @@ -0,0 +1,175 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.details + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.components.PageTitle +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.mediaviewer.api.anImageMediaInfo +import io.element.android.libraries.mediaviewer.impl.R +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MediaDeleteConfirmationBottomSheet( + state: MediaBottomSheetState.MediaDeleteConfirmationState, + onDelete: (EventId) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + ModalBottomSheet( + modifier = modifier, + onDismissRequest = onDismiss, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + PageTitle( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp, horizontal = 8.dp), + title = stringResource(R.string.screen_media_browser_delete_confirmation_title), + iconStyle = BigIcon.Style.Default(CompoundIcons.Delete(), useCriticalTint = true), + subtitle = stringResource(R.string.screen_media_browser_delete_confirmation_subtitle), + ) + MediaRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + state = state, + ) + Button( + modifier = Modifier + .fillMaxWidth() + .padding(top = 40.dp), + text = stringResource(CommonStrings.action_remove), + onClick = { + onDelete(state.eventId) + }, + destructive = true, + ) + TextButton( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + text = stringResource(CommonStrings.action_cancel), + onClick = { + onDismiss() + }, + ) + } + } +} + +@Composable +private fun MediaRow( + state: MediaBottomSheetState.MediaDeleteConfirmationState, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + val bgColor = if (LocalInspectionMode.current) { + ElementTheme.colors.bgDecorative1 + } else { + Color.Transparent + } + Box( + modifier = Modifier + .size(40.dp) + .background(bgColor), + ) { + if (state.thumbnailSource == null) { + BigIcon( + style = BigIcon.Style.Default(CompoundIcons.Attachment()), + ) + } else { + AsyncImage( + modifier = Modifier + .fillMaxWidth() + .background(Color.White), + model = MediaRequestData(state.thumbnailSource, MediaRequestData.Kind.Thumbnail(100)), + contentScale = ContentScale.Crop, + alignment = Alignment.Center, + contentDescription = null, + ) + } + } + Column( + modifier = Modifier + .padding(start = 12.dp) + .weight(1f), + ) { + // Name + Text( + modifier = Modifier.clipToBounds(), + text = state.mediaInfo.filename, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyLgRegular, + ) + // Info + Text( + text = state.mediaInfo.mimeType + " - " + state.mediaInfo.formattedFileSize, + color = MaterialTheme.colorScheme.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodySmRegular, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun MediaDeleteConfirmationBottomSheetPreview() = ElementPreview { + MediaDeleteConfirmationBottomSheet( + state = MediaBottomSheetState.MediaDeleteConfirmationState( + eventId = EventId("\$eventId"), + mediaInfo = anImageMediaInfo( + senderName = "Alice", + ), + thumbnailSource = null, + ), + onDelete = {}, + onDismiss = {}, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheet.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheet.kt new file mode 100644 index 0000000000..9e2109c978 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheet.kt @@ -0,0 +1,210 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +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.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.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.colors.AvatarColorsProvider +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.list.ListItemContent +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.IconSource +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ListItemStyle +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.api.anImageMediaInfo +import io.element.android.libraries.mediaviewer.impl.R +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MediaDetailsBottomSheet( + state: MediaBottomSheetState.MediaDetailsBottomSheetState, + onViewInTimeline: (EventId) -> Unit, + onDelete: (EventId) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + ModalBottomSheet( + modifier = modifier, + onDismissRequest = onDismiss, + ) { + Column( + modifier = Modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + Section( + title = stringResource(R.string.screen_media_details_uploaded_by), + ) { + SenderRow( + mediaInfo = state.mediaInfo, + ) + } + SectionText( + title = stringResource(R.string.screen_media_details_uploaded_on), + text = state.mediaInfo.dateSent.orEmpty(), + ) + SectionText( + title = stringResource(R.string.screen_media_details_filename), + text = state.mediaInfo.filename, + ) + SectionText( + title = stringResource(R.string.screen_media_details_file_format), + text = state.mediaInfo.mimeType + " - " + state.mediaInfo.formattedFileSize, + ) + 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) + } + ) + 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)) + } + } + } + } +} + +@Composable +private fun SenderRow( + mediaInfo: MediaInfo, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + val id = mediaInfo.senderId?.value ?: "@Alice:domain" + Avatar( + AvatarData( + id = id, + name = mediaInfo.senderName, + url = mediaInfo.senderAvatar, + size = AvatarSize.MediaSender, + ) + ) + Column( + modifier = Modifier + .padding(start = 8.dp) + .weight(1f), + ) { + // Name + val avatarColors = AvatarColorsProvider.provide(id) + Text( + modifier = Modifier.clipToBounds(), + text = mediaInfo.senderName.orEmpty(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = avatarColors.foreground, + style = ElementTheme.typography.fontBodyMdMedium, + ) + // Id + Text( + text = mediaInfo.senderId?.value.orEmpty(), + color = MaterialTheme.colorScheme.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyMdRegular, + ) + } + } +} + +@Composable +private fun Section( + title: String, + content: @Composable () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = title.uppercase(), + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + content() + } +} + +@Composable +private fun SectionText( + title: String, + text: String, +) { + Section(title = title) { + Text( + text = text, + style = ElementTheme.typography.fontBodyLgRegular, + color = ElementTheme.colors.textPrimary, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun MediaDetailsBottomSheetPreview() = ElementPreview { + MediaDetailsBottomSheet( + state = MediaBottomSheetState.MediaDetailsBottomSheetState( + eventId = EventId("\$eventId"), + canDelete = true, + mediaInfo = anImageMediaInfo( + senderName = "Alice", + ), + thumbnailSource = null, + ), + onViewInTimeline = {}, + onDelete = {}, + onDismiss = {}, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt new file mode 100644 index 0000000000..d4065f76c2 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt @@ -0,0 +1,194 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.androidutils.filesize.FileSizeFormatter +import io.element.android.libraries.dateformatter.api.toHumanReadableDuration +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent +import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent +import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent +import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent +import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.PollContent +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent +import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent +import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent +import io.element.android.libraries.matrix.api.timeline.item.event.StateContent +import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent +import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent +import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent +import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType +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.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor +import timber.log.Timber +import java.text.DateFormat +import java.util.Date +import javax.inject.Inject + +interface EventItemFactory { + fun create(currentTimelineItem: MatrixTimelineItem.Event): MediaItem.Event? +} + +@ContributesBinding(AppScope::class) +class DefaultEventItemFactory @Inject constructor( + private val fileSizeFormatter: FileSizeFormatter, + private val fileExtensionExtractor: FileExtensionExtractor, +) : EventItemFactory { + private val timeFormatter = DateFormat.getDateInstance() + + override fun create( + currentTimelineItem: MatrixTimelineItem.Event, + ): MediaItem.Event? { + val event = currentTimelineItem.event + val sentTime = timeFormatter.format(Date(currentTimelineItem.event.timestamp)) + return when (val content = event.content) { + CallNotifyContent, + is FailedToParseMessageLikeContent, + is FailedToParseStateContent, + LegacyCallInviteContent, + is PollContent, + is ProfileChangeContent, + RedactedContent, + is RoomMembershipContent, + is StateContent, + is StickerContent, + is UnableToDecryptContent, + UnknownContent -> { + Timber.w("Should not happen: ${content.javaClass.simpleName}") + null + } + is MessageContent -> { + when (val type = content.type) { + is EmoteMessageType, + is NoticeMessageType, + is OtherMessageType, + is LocationMessageType, + is TextMessageType -> { + Timber.w("Should not happen: ${content.type}") + null + } + is AudioMessageType -> MediaItem.File( + id = currentTimelineItem.uniqueId, + eventId = currentTimelineItem.eventId, + mediaInfo = MediaInfo( + filename = type.filename, + caption = type.caption, + mimeType = type.info?.mimetype.orEmpty(), + formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(), + fileExtension = fileExtensionExtractor.extractFromName(type.filename), + senderId = event.sender, + senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender), + senderAvatar = event.senderProfile.getAvatarUrl(), + dateSent = sentTime, + ), + mediaSource = type.source, + ) + is FileMessageType -> MediaItem.File( + id = currentTimelineItem.uniqueId, + eventId = currentTimelineItem.eventId, + mediaInfo = MediaInfo( + filename = type.filename, + caption = type.caption, + mimeType = type.info?.mimetype.orEmpty(), + formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(), + fileExtension = fileExtensionExtractor.extractFromName(type.filename), + senderId = event.sender, + senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender), + senderAvatar = event.senderProfile.getAvatarUrl(), + dateSent = sentTime, + ), + mediaSource = type.source, + ) + is ImageMessageType -> MediaItem.Image( + id = currentTimelineItem.uniqueId, + eventId = currentTimelineItem.eventId, + mediaInfo = MediaInfo( + filename = type.filename, + caption = type.caption, + mimeType = type.info?.mimetype.orEmpty(), + formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(), + fileExtension = fileExtensionExtractor.extractFromName(type.filename), + senderId = event.sender, + senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender), + senderAvatar = event.senderProfile.getAvatarUrl(), + dateSent = sentTime, + ), + mediaSource = type.source, + thumbnailSource = null, + ) + is StickerMessageType -> MediaItem.Image( + id = currentTimelineItem.uniqueId, + eventId = currentTimelineItem.eventId, + mediaInfo = MediaInfo( + filename = type.filename, + caption = type.caption, + mimeType = type.info?.mimetype.orEmpty(), + formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(), + fileExtension = fileExtensionExtractor.extractFromName(type.filename), + senderId = event.sender, + senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender), + senderAvatar = event.senderProfile.getAvatarUrl(), + dateSent = sentTime, + ), + mediaSource = type.source, + thumbnailSource = null, + ) + is VideoMessageType -> MediaItem.Video( + id = currentTimelineItem.uniqueId, + eventId = currentTimelineItem.eventId, + mediaInfo = MediaInfo( + filename = type.filename, + caption = type.caption, + mimeType = type.info?.mimetype.orEmpty(), + formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(), + fileExtension = fileExtensionExtractor.extractFromName(type.filename), + senderId = event.sender, + senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender), + senderAvatar = event.senderProfile.getAvatarUrl(), + dateSent = sentTime, + ), + mediaSource = type.source, + thumbnailSource = type.info?.thumbnailSource, + duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(), + ) + is VoiceMessageType -> MediaItem.File( + id = currentTimelineItem.uniqueId, + eventId = currentTimelineItem.eventId, + mediaInfo = MediaInfo( + filename = type.filename, + caption = type.caption, + mimeType = type.info?.mimetype.orEmpty(), + formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(), + fileExtension = fileExtensionExtractor.extractFromName(type.filename), + senderId = event.sender, + senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender), + senderAvatar = event.senderProfile.getAvatarUrl(), + dateSent = sentTime, + ), + mediaSource = type.source, + ) + } + } + } + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryEvents.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryEvents.kt new file mode 100644 index 0000000000..717ba4edbb --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryEvents.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +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.mediaviewer.api.MediaInfo + +sealed interface MediaGalleryEvents { + data class ChangeMode(val mode: MediaGalleryMode) : MediaGalleryEvents + data class LoadMore(val direction: Timeline.PaginationDirection) : MediaGalleryEvents + data class Share(val mediaItem: MediaItem.Event) : MediaGalleryEvents + data class SaveOnDisk(val mediaItem: MediaItem.Event) : MediaGalleryEvents + data class OpenInfo(val mediaItem: MediaItem.Event) : MediaGalleryEvents + data class ViewInTimeline(val eventId: EventId) : MediaGalleryEvents + + data class ConfirmDelete( + val eventId: EventId, + val mediaInfo: MediaInfo, + val thumbnailSource: MediaSource?, + ) : MediaGalleryEvents + + data object CloseBottomSheet : MediaGalleryEvents + data class Delete(val eventId: EventId) : MediaGalleryEvents +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNavigator.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNavigator.kt new file mode 100644 index 0000000000..7ae729309a --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNavigator.kt @@ -0,0 +1,14 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +import io.element.android.libraries.matrix.api.core.EventId + +interface MediaGalleryNavigator { + fun onViewInTimelineClick(eventId: EventId) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNode.kt new file mode 100644 index 0000000000..4ec91570ef --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNode.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.EventId + +@ContributesNode(RoomScope::class) +class MediaGalleryNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: MediaGalleryPresenter.Factory, +) : Node(buildContext, plugins = plugins), + MediaGalleryNavigator { + private val presenter = presenterFactory.create( + navigator = this, + ) + + interface Callback : Plugin { + fun onDone() + fun onItemClick(item: MediaItem.Event) + fun onViewInTimeline(eventId: EventId) + } + + private fun onDone() { + plugins().forEach { + it.onDone() + } + } + + override fun onViewInTimelineClick(eventId: EventId) { + plugins().forEach { + it.onViewInTimeline(eventId) + } + } + + private fun onItemClick(item: MediaItem.Event) { + plugins().forEach { + it.onItemClick(item) + } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + MediaGalleryView( + state = state, + onBackClick = ::onDone, + onItemClick = ::onItemClick, + modifier = modifier, + ) + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt new file mode 100644 index 0000000000..5bd170f672 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt @@ -0,0 +1,264 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +import android.content.ActivityNotFoundException +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.element.android.libraries.androidutils.R +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.media.MatrixMediaLoader +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther +import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory +import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState +import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +class MediaGalleryPresenter @AssistedInject constructor( + @Assisted private val navigator: MediaGalleryNavigator, + private val room: MatrixRoom, + private val timelineProvider: MediaGalleryTimelineProvider, + private val timelineMediaItemsFactory: TimelineMediaItemsFactory, + private val localMediaFactory: LocalMediaFactory, + private val mediaLoader: MatrixMediaLoader, + private val localMediaActions: LocalMediaActions, + private val snackbarDispatcher: SnackbarDispatcher, + private val mediaItemsPostProcessor: MediaItemsPostProcessor, +) : Presenter { + @AssistedFactory + interface Factory { + fun create( + navigator: MediaGalleryNavigator, + ): MediaGalleryPresenter + } + + @Composable + override fun present(): MediaGalleryState { + val coroutineScope = rememberCoroutineScope() + var mode by remember { mutableStateOf(MediaGalleryMode.Images) } + + val roomInfo by room.roomInfoFlow.collectAsState(null) + + var mediaBottomSheetState by remember { mutableStateOf(MediaBottomSheetState.Hidden) } + + var mediaItems by remember { + mutableStateOf>>(AsyncData.Uninitialized) + } + val imageItems by remember { + derivedStateOf { + mediaItemsPostProcessor.process( + mediaItems = mediaItems, + predicate = { it is MediaItem.Image || it is MediaItem.Video }, + ) + } + } + val fileItems by remember { + derivedStateOf { + mediaItemsPostProcessor.process( + mediaItems = mediaItems, + predicate = { it is MediaItem.File }, + ) + } + } + + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() + localMediaActions.Configure() + + MediaListEffect( + onItemsChange = { newItems -> + mediaItems = newItems + } + ) + + LaunchedEffect(Unit) { + timelineProvider.launchIn(this) + } + + fun handleEvents(event: MediaGalleryEvents) { + when (event) { + is MediaGalleryEvents.ChangeMode -> { + mode = event.mode + } + is MediaGalleryEvents.LoadMore -> coroutineScope.launch { + timelineProvider.invokeOnTimeline { + paginate(event.direction) + } + } + is MediaGalleryEvents.Delete -> coroutineScope.delete(event.eventId) + is MediaGalleryEvents.SaveOnDisk -> coroutineScope.saveOnDisk(event.mediaItem) + is MediaGalleryEvents.Share -> coroutineScope.share(event.mediaItem) + is MediaGalleryEvents.ViewInTimeline -> { + mediaBottomSheetState = MediaBottomSheetState.Hidden + navigator.onViewInTimelineClick(event.eventId) + } + is MediaGalleryEvents.OpenInfo -> coroutineScope.launch { + mediaBottomSheetState = MediaBottomSheetState.MediaDetailsBottomSheetState( + eventId = event.mediaItem.eventId(), + canDelete = when (event.mediaItem.mediaInfo().senderId) { + null -> false + room.sessionId -> room.canRedactOwn().getOrElse { false } && event.mediaItem.eventId() != null + else -> room.canRedactOther().getOrElse { false } && event.mediaItem.eventId() != null + + }, + mediaInfo = event.mediaItem.mediaInfo(), + thumbnailSource = when (event.mediaItem) { + is MediaItem.Image -> event.mediaItem.thumbnailSource ?: event.mediaItem.mediaSource + is MediaItem.Video -> event.mediaItem.thumbnailSource ?: event.mediaItem.mediaSource + is MediaItem.File -> null + }, + ) + } + is MediaGalleryEvents.ConfirmDelete -> { + mediaBottomSheetState = MediaBottomSheetState.MediaDeleteConfirmationState( + eventId = event.eventId, + mediaInfo = event.mediaInfo, + thumbnailSource = event.thumbnailSource, + ) + } + MediaGalleryEvents.CloseBottomSheet -> { + mediaBottomSheetState = MediaBottomSheetState.Hidden + } + } + } + + return MediaGalleryState( + roomName = roomInfo?.name ?: room.displayName, + mode = mode, + imageItems = imageItems, + fileItems = fileItems, + mediaBottomSheetState = mediaBottomSheetState, + snackbarMessage = snackbarMessage, + eventSink = ::handleEvents + ) + } + + @Composable + private fun MediaListEffect(onItemsChange: (AsyncData>) -> Unit) { + val updatedOnItemsChange by rememberUpdatedState(onItemsChange) + + val timelineState by timelineProvider.timelineStateFlow.collectAsState() + + LaunchedEffect(timelineState) { + when (val asyncTimeline = timelineState) { + AsyncData.Uninitialized -> flowOf(AsyncData.Uninitialized) + is AsyncData.Failure -> flowOf(AsyncData.Failure(asyncTimeline.error)) + is AsyncData.Loading -> flowOf(AsyncData.Loading()) + is AsyncData.Success -> { + asyncTimeline.data.timelineItems + .onEach { items -> + timelineMediaItemsFactory.replaceWith( + timelineItems = items, + ) + } + .launchIn(this) + + asyncTimeline.data.paginationStatus(Timeline.PaginationDirection.BACKWARDS) + .onEach { backwardPaginationStatus -> + if (backwardPaginationStatus.canPaginate) { + timelineMediaItemsFactory.onCanPaginate() + } + } + .launchIn(this) + + timelineMediaItemsFactory.timelineItems.map { timelineItems -> + AsyncData.Success(timelineItems) + } + } + } + .onEach { items -> + updatedOnItemsChange(items) + } + .launchIn(this) + } + } + + private fun CoroutineScope.delete(eventId: EventId) = launch { + timelineProvider.invokeOnTimeline { + redactEvent( + eventOrTransactionId = eventId.toEventOrTransactionId(), + reason = null, + ) + } + } + + private suspend fun downloadMedia(mediaItem: MediaItem.Event): Result { + return mediaLoader.downloadMediaFile( + source = mediaItem.mediaSource(), + mimeType = mediaItem.mediaInfo().mimeType, + filename = mediaItem.mediaInfo().filename + ) + .mapCatching { mediaFile -> + localMediaFactory.createFromMediaFile( + mediaFile = mediaFile, + mediaInfo = mediaItem.mediaInfo() + ) + } + } + + private fun CoroutineScope.saveOnDisk(mediaItem: MediaItem.Event) = launch { + downloadMedia(mediaItem) + .mapCatching { localMedia -> + localMediaActions.saveOnDisk(localMedia) + } + .onSuccess { + val snackbarMessage = SnackbarMessage(CommonStrings.common_file_saved_on_disk_android) + snackbarDispatcher.post(snackbarMessage) + } + .onFailure { + val snackbarMessage = SnackbarMessage(mediaActionsError(it)) + snackbarDispatcher.post(snackbarMessage) + } + } + + private fun CoroutineScope.share(mediaItem: MediaItem.Event) = launch { + downloadMedia(mediaItem) + .mapCatching { localMedia -> + localMediaActions.share(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 + } else { + CommonStrings.error_unknown + } + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryState.kt new file mode 100644 index 0000000000..36e0710a88 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryState.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.mediaviewer.impl.R +import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState +import kotlinx.collections.immutable.ImmutableList + +data class MediaGalleryState( + val roomName: String, + val mode: MediaGalleryMode, + val imageItems: AsyncData>, + val fileItems: AsyncData>, + val mediaBottomSheetState: MediaBottomSheetState, + val snackbarMessage: SnackbarMessage?, + val eventSink: (MediaGalleryEvents) -> Unit, +) + +enum class MediaGalleryMode(val stringResource: Int) { + Images(R.string.screen_media_browser_list_mode_media), + Files(R.string.screen_media_browser_list_mode_files), +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt new file mode 100644 index 0000000000..d0a92f1b5d --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState +import io.element.android.libraries.mediaviewer.impl.gallery.ui.aDate +import io.element.android.libraries.mediaviewer.impl.gallery.ui.aFile +import io.element.android.libraries.mediaviewer.impl.gallery.ui.aVideo +import io.element.android.libraries.mediaviewer.impl.gallery.ui.anImage +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentList + +open class MediaGalleryStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aMediaGalleryState(), + aMediaGalleryState(imageItems = AsyncData.Loading()), + aMediaGalleryState(imageItems = AsyncData.Success(emptyList().toPersistentList())), + aMediaGalleryState( + imageItems = AsyncData.Success( + listOf( + aDate(), + anImage(), + aDate(), + anImage(), + aVideo(), + anImage(), + anImage(), + anImage(), + anImage(), + anImage(), + ).toImmutableList() + ) + ), + aMediaGalleryState(mode = MediaGalleryMode.Files), + aMediaGalleryState(mode = MediaGalleryMode.Files, fileItems = AsyncData.Loading()), + aMediaGalleryState(mode = MediaGalleryMode.Files, fileItems = AsyncData.Success(emptyList().toPersistentList())), + aMediaGalleryState(mode = MediaGalleryMode.Files, fileItems = AsyncData.Success(emptyList().toPersistentList())), + aMediaGalleryState( + mode = MediaGalleryMode.Files, + fileItems = AsyncData.Success( + listOf( + aDate(), + aFile(), + aDate(), + aFile(), + aFile(), + aFile(), + aFile(), + ).toImmutableList() + ) + ), + ) +} + +private fun aMediaGalleryState( + roomName: String = "Room name", + mode: MediaGalleryMode = MediaGalleryMode.Images, + imageItems: AsyncData> = AsyncData.Uninitialized, + fileItems: AsyncData> = AsyncData.Uninitialized, +) = MediaGalleryState( + roomName = roomName, + mode = mode, + imageItems = imageItems, + fileItems = fileItems, + mediaBottomSheetState = MediaBottomSheetState.Hidden, + snackbarMessage = null, + eventSink = {} +) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryTimelineProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryTimelineProvider.kt new file mode 100644 index 0000000000..9174cab8ca --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryTimelineProvider.kt @@ -0,0 +1,113 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +import io.element.android.features.networkmonitor.api.NetworkMonitor +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.coroutine.mapState +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.TimelineProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject + +@SingleIn(RoomScope::class) +class MediaGalleryTimelineProvider @Inject constructor( + private val room: MatrixRoom, + private val networkMonitor: NetworkMonitor, + private val featureFlagService: FeatureFlagService, +) : TimelineProvider { + private val _timelineStateFlow: MutableStateFlow> = + MutableStateFlow(AsyncData.Uninitialized) + + override fun activeTimelineFlow(): StateFlow { + return _timelineStateFlow + .mapState { value -> + value.dataOrNull() + } + } + + val timelineStateFlow = _timelineStateFlow + + fun launchIn(scope: CoroutineScope) { + _timelineStateFlow.subscriptionCount + .map { count -> count > 0 } + .distinctUntilChanged() + .onEach { isActive -> + if (isActive) { + onActive() + } else { + onInactive() + } + } + .launchIn(scope) + } + + private suspend fun onActive() = coroutineScope { + combine( + featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaGallery), + networkMonitor.connectivity + ) { isEnabled, _ -> + // do not use connectivity here as data can be loaded from cache, it's just to trigger retry if needed + isEnabled + } + .onEach { isFeatureEnabled -> + if (isFeatureEnabled) { + loadTimelineIfNeeded() + } else { + resetTimeline() + } + } + .launchIn(this) + } + + private suspend fun onInactive() { + resetTimeline() + } + + private suspend fun resetTimeline() { + invokeOnTimeline { + close() + } + _timelineStateFlow.emit(AsyncData.Uninitialized) + } + + suspend fun invokeOnTimeline(action: suspend Timeline.() -> Unit) { + when (val asyncTimeline = timelineStateFlow.value) { + is AsyncData.Success -> action(asyncTimeline.data) + else -> Unit + } + } + + private suspend fun loadTimelineIfNeeded() { + when (timelineStateFlow.value) { + is AsyncData.Uninitialized, + is AsyncData.Failure -> { + timelineStateFlow.emit(AsyncData.Loading()) + room.mediaTimeline() + .fold( + { timelineStateFlow.emit(AsyncData.Success(it)) }, + { timelineStateFlow.emit(AsyncData.Failure(it)) } + ) + } + else -> Unit + } + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt new file mode 100644 index 0000000000..ac97755301 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt @@ -0,0 +1,436 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.components.PageTitle +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.LinearProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.SegmentedButton +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.designsystem.utils.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.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 +import io.element.android.libraries.mediaviewer.impl.gallery.ui.DateItemView +import io.element.android.libraries.mediaviewer.impl.gallery.ui.FileItemView +import io.element.android.libraries.mediaviewer.impl.gallery.ui.ImageItemView +import io.element.android.libraries.mediaviewer.impl.gallery.ui.VideoItemView +import kotlinx.collections.immutable.ImmutableList +import kotlin.math.max + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MediaGalleryView( + state: MediaGalleryState, + onBackClick: () -> Unit, + onItemClick: (MediaItem.Event) -> Unit, + modifier: Modifier = Modifier, +) { + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) + BackHandler { onBackClick() } + Scaffold( + modifier = modifier, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { + Text( + text = state.roomName, + style = ElementTheme.typography.aliasScreenTitle, + ) + }, + navigationIcon = { + BackButton( + onClick = onBackClick, + ) + }, + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + .fillMaxSize() + ) { + SingleChoiceSegmentedButtonRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + MediaGalleryMode.entries.forEach { mode -> + SegmentedButton( + index = mode.ordinal, + count = MediaGalleryMode.entries.size, + selected = state.mode == mode, + onClick = { state.eventSink(MediaGalleryEvents.ChangeMode(mode)) }, + text = stringResource(mode.stringResource), + ) + } + } + val pagerState = rememberPagerState(0, 0f) { + MediaGalleryMode.entries.size + } + LaunchedEffect(state.mode) { + pagerState.scrollToPage(state.mode.ordinal) + } + HorizontalPager( + state = pagerState, + userScrollEnabled = false, + modifier = Modifier, + ) { page -> + val mode = MediaGalleryMode.entries[page] + when (mode) { + MediaGalleryMode.Images -> MediaGalleryImages( + images = state.imageItems, + eventSink = state.eventSink, + onItemClick = onItemClick, + ) + MediaGalleryMode.Files -> MediaGalleryFiles( + files = state.fileItems, + eventSink = state.eventSink, + onItemClick = onItemClick, + ) + } + } + } + } + when (val bottomSheetState = state.mediaBottomSheetState) { + MediaBottomSheetState.Hidden -> Unit + is MediaBottomSheetState.MediaDetailsBottomSheetState -> { + MediaDetailsBottomSheet( + state = bottomSheetState, + onViewInTimeline = { eventId -> + state.eventSink(MediaGalleryEvents.ViewInTimeline(eventId)) + }, + onDelete = { eventId -> + state.eventSink( + MediaGalleryEvents.ConfirmDelete( + eventId = eventId, + mediaInfo = bottomSheetState.mediaInfo, + thumbnailSource = bottomSheetState.thumbnailSource, + ) + ) + }, + onDismiss = { + state.eventSink(MediaGalleryEvents.CloseBottomSheet) + }, + ) + } + is MediaBottomSheetState.MediaDeleteConfirmationState -> { + MediaDeleteConfirmationBottomSheet( + state = bottomSheetState, + onDelete = { + state.eventSink(MediaGalleryEvents.Delete(it)) + }, + onDismiss = { + state.eventSink(MediaGalleryEvents.CloseBottomSheet) + }, + ) + } + } +} + +@Composable +private fun MediaGalleryImages( + images: AsyncData>, + eventSink: (MediaGalleryEvents) -> Unit, + onItemClick: (MediaItem.Event) -> Unit, +) { + when (images) { + AsyncData.Uninitialized, + is AsyncData.Loading -> { + LoadingContent(MediaGalleryMode.Images) + } + is AsyncData.Success -> { + if (images.data.isEmpty()) { + EmptyContent() + } else { + MediaGalleryImageGrid( + images = images.data, + eventSink = eventSink, + onItemClick = onItemClick, + ) + } + } + is AsyncData.Failure -> { + ErrorContent( + error = images.error, + ) + } + } +} + +@Composable +private fun MediaGalleryFiles( + files: AsyncData>, + eventSink: (MediaGalleryEvents) -> Unit, + onItemClick: (MediaItem.Event) -> Unit, +) { + when (files) { + AsyncData.Uninitialized, + is AsyncData.Loading -> { + LoadingContent(MediaGalleryMode.Files) + } + is AsyncData.Success -> { + if (files.data.isEmpty()) { + EmptyContent() + } else { + MediaGalleryFilesList( + files = files.data, + eventSink = eventSink, + onItemClick = onItemClick, + ) + } + } + is AsyncData.Failure -> { + ErrorContent( + error = files.error, + ) + } + } +} + +@Composable +private fun MediaGalleryFilesList( + files: ImmutableList, + eventSink: (MediaGalleryEvents) -> Unit, + onItemClick: (MediaItem.Event) -> Unit, +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + items(files) { item -> + when (item) { + is MediaItem.File -> FileItemView( + item, + onClick = { onItemClick(item) }, + onShareClick = { eventSink(MediaGalleryEvents.Share(item)) }, + onDownloadClick = { eventSink(MediaGalleryEvents.SaveOnDisk(item)) }, + onInfoClick = { eventSink(MediaGalleryEvents.OpenInfo(item)) }, + ) + is MediaItem.DateSeparator -> DateItemView(item) + is MediaItem.Image, + is MediaItem.Video -> { + // Should not happen + } + is MediaItem.LoadingIndicator -> { + LoadingMoreIndicator(item.direction) + val latestEventSink by rememberUpdatedState(eventSink) + LaunchedEffect(item.timestamp) { + latestEventSink(MediaGalleryEvents.LoadMore(item.direction)) + } + } + } + } + } +} + +@Composable +private fun MediaGalleryImageGrid( + images: ImmutableList, + eventSink: (MediaGalleryEvents) -> Unit, + onItemClick: (MediaItem.Event) -> Unit, +) { + val configuration = LocalConfiguration.current + val screenWidth = configuration.screenWidthDp.dp + val horizontalPadding = 16.dp + val itemSpacing = 4.dp + val availableWidth = screenWidth - horizontalPadding * 2 + val minCellWidth = 80.dp + // Calculate the number of columns + val columns = max(1, (availableWidth / (minCellWidth + itemSpacing)).toInt()) + LazyVerticalGrid( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = horizontalPadding), + columns = GridCells.Fixed(columns), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items( + images, + span = { item -> + when (item) { + is MediaItem.LoadingIndicator, + is MediaItem.DateSeparator -> GridItemSpan(columns) + is MediaItem.Image, + is MediaItem.Video, + is MediaItem.File -> GridItemSpan(1) + } + }, + key = { it.id() }, + contentType = { it::class.java }, + ) { item -> + when (item) { + is MediaItem.DateSeparator -> { + DateItemView(item) + } + is MediaItem.File -> { + // Should not happen + } + is MediaItem.Image -> { + ImageItemView( + item, + onClick = { onItemClick(item) }, + onLongClick = { + eventSink(MediaGalleryEvents.OpenInfo(item)) + }, + ) + } + is MediaItem.Video -> { + VideoItemView( + item, + onClick = { onItemClick(item) }, + onLongClick = { + eventSink(MediaGalleryEvents.OpenInfo(item)) + }, + ) + } + is MediaItem.LoadingIndicator -> { + LoadingMoreIndicator(item.direction) + val latestEventSink by rememberUpdatedState(eventSink) + LaunchedEffect(item.timestamp) { + latestEventSink(MediaGalleryEvents.LoadMore(item.direction)) + } + } + } + } + } +} + +@Composable +private fun LoadingMoreIndicator( + direction: Timeline.PaginationDirection, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + when (direction) { + Timeline.PaginationDirection.FORWARDS -> { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp) + .height(1.dp) + ) + } + Timeline.PaginationDirection.BACKWARDS -> { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.padding(vertical = 8.dp) + ) + } + } + } +} + +@Composable +private fun ErrorContent(error: Throwable) { + // TODO + Text("Error: $error") +} + +@Composable +private fun EmptyContent( +) { + Box( + modifier = Modifier.fillMaxSize(), + ) { + PageTitle( + modifier = Modifier + .fillMaxWidth() + .padding(top = 44.dp) + .padding(24.dp), + title = stringResource(R.string.screen_media_browser_empty_state_title), + iconStyle = BigIcon.Style.Default(CompoundIcons.Image()), + subtitle = stringResource(R.string.screen_media_browser_empty_state_subtitle), + ) + } +} + +@Composable +private fun LoadingContent( + mode: MediaGalleryMode, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = 48.dp) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularProgressIndicator() + val res = when (mode) { + MediaGalleryMode.Images -> R.string.screen_media_browser_list_loading_media + MediaGalleryMode.Files -> R.string.screen_media_browser_list_loading_files + } + Text( + text = stringResource(res), + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } +} + +@PreviewsDayNight +@Composable +internal fun MediaGalleryViewPreview( + @PreviewParameter(MediaGalleryStateProvider::class) state: MediaGalleryState +) = ElementPreview { + MediaGalleryView( + state = state, + onBackClick = {}, + onItemClick = {}, + ) +} + diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItem.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItem.kt new file mode 100644 index 0000000000..f43387fdb6 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItem.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +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.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.mediaviewer.api.MediaInfo + +sealed interface MediaItem { + data class DateSeparator( + val id: UniqueId, + val formattedDate: String, + ) : MediaItem + + data class LoadingIndicator( + val id: UniqueId, + val direction: Timeline.PaginationDirection, + val timestamp: Long, + ) : MediaItem + + sealed interface Event : MediaItem + + data class Image( + val id: UniqueId, + val eventId: EventId?, + val mediaInfo: MediaInfo, + val mediaSource: MediaSource, + val thumbnailSource: MediaSource?, + ) : Event { + val thumbnailMediaRequestData: MediaRequestData + get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100)) + } + + data class Video( + val id: UniqueId, + val eventId: EventId?, + val mediaInfo: MediaInfo, + val mediaSource: MediaSource, + val thumbnailSource: MediaSource?, + val duration: String?, + ) : Event { + val thumbnailMediaRequestData: MediaRequestData + get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100)) + } + + data class File( + val id: UniqueId, + val eventId: EventId?, + val mediaInfo: MediaInfo, + val mediaSource: MediaSource, + ) : Event +} + +fun MediaItem.id(): UniqueId { + return when (this) { + is MediaItem.DateSeparator -> id + is MediaItem.LoadingIndicator -> id + is MediaItem.Image -> id + is MediaItem.Video -> id + is MediaItem.File -> id + } +} + +fun MediaItem.Event.eventId(): EventId? { + return when (this) { + is MediaItem.Image -> eventId + is MediaItem.Video -> eventId + is MediaItem.File -> eventId + } +} + +fun MediaItem.Event.mediaInfo(): MediaInfo { + return when (this) { + is MediaItem.Image -> mediaInfo + is MediaItem.Video -> mediaInfo + is MediaItem.File -> mediaInfo + } +} + +fun MediaItem.Event.mediaSource(): MediaSource { + return when (this) { + is MediaItem.Image -> mediaSource + is MediaItem.Video -> mediaSource + is MediaItem.File -> mediaSource + } +} + +fun MediaItem.Event.thumbnailSource(): MediaSource? { + return when (this) { + is MediaItem.Image -> thumbnailSource + is MediaItem.Video -> thumbnailSource + is MediaItem.File -> null + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt new file mode 100644 index 0000000000..d406a34567 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.di.AppScope +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import javax.inject.Inject + +interface MediaItemsPostProcessor { + fun process( + mediaItems: AsyncData>, + predicate: (MediaItem.Event) -> Boolean, + ): AsyncData> +} + +@ContributesBinding(AppScope::class) +class DefaultMediaItemsPostProcessor @Inject constructor( +) : MediaItemsPostProcessor { + override fun process( + mediaItems: AsyncData>, + predicate: (MediaItem.Event) -> Boolean, + ): AsyncData> { + return when (mediaItems) { + is AsyncData.Uninitialized -> mediaItems + is AsyncData.Loading -> mediaItems + is AsyncData.Failure -> mediaItems + is AsyncData.Success -> AsyncData.Success( + process( + mediaItems = mediaItems.data, + predicate = predicate, + ) + ) + } + } + + private fun process( + mediaItems: List, + predicate: (MediaItem.Event) -> Boolean, + ) = buildList { + val eventList = mutableListOf() + for (item in mediaItems) { + when (item) { + is MediaItem.DateSeparator -> { + if (eventList.isNotEmpty()) { + // Date separator first + add(item) + // Then events + addAll(eventList) + eventList.clear() + } + } + is MediaItem.Event -> { + if (predicate(item)) { + eventList.add(item) + } + } + is MediaItem.LoadingIndicator -> { + add(item) + } + } + } + }.toImmutableList() +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaItemsFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaItemsFactory.kt new file mode 100644 index 0000000000..1993381417 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaItemsFactory.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.androidutils.diff.DiffCacheUpdater +import io.element.android.libraries.androidutils.diff.MutableListDiffCache +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.mediaviewer.impl.gallery.diff.TimelineMediaItemsCacheInvalidator +import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject + +interface TimelineMediaItemsFactory { + val timelineItems: Flow> + + suspend fun replaceWith(timelineItems: List) + suspend fun onCanPaginate() +} + +@ContributesBinding(AppScope::class) +class DefaultTimelineMediaItemsFactory @Inject constructor( + private val dispatchers: CoroutineDispatchers, + private val virtualItemFactory: VirtualItemFactory, + private val eventItemFactory: EventItemFactory, + private val systemClock: SystemClock, +) : TimelineMediaItemsFactory { + private val _timelineItems = MutableSharedFlow>(replay = 1) + private val lock = Mutex() + private val diffCache = MutableListDiffCache() + private val diffCacheUpdater = DiffCacheUpdater( + diffCache = diffCache, + detectMoves = false, + cacheInvalidator = TimelineMediaItemsCacheInvalidator() + ) { old, new -> + if (old is MatrixTimelineItem.Event && new is MatrixTimelineItem.Event) { + old.uniqueId == new.uniqueId + } else { + false + } + } + + override val timelineItems: Flow> = _timelineItems.distinctUntilChanged() + + override suspend fun replaceWith( + timelineItems: List, + ) = withContext(dispatchers.computation) { + lock.withLock { + diffCacheUpdater.updateWith(timelineItems) + buildAndEmitTimelineItemStates(timelineItems) + } + } + + /** + * Update the timestamp of the loading indicator, so that it may trigger a new pagination request. + */ + override suspend fun onCanPaginate() { + lock.withLock { + val values = _timelineItems.replayCache.firstOrNull() ?: return@withLock + val lastItem = values.lastOrNull() + if (lastItem is MediaItem.LoadingIndicator) { + val newList = values.toMutableList().apply { + removeAt(size - 1) + val newTs = systemClock.epochMillis() + add(lastItem.copy(timestamp = newTs)) + } + _timelineItems.emit(newList.toPersistentList()) + } else { + Timber.w("onCanPaginate called but last item is not a loading indicator") + } + } + } + + private suspend fun buildAndEmitTimelineItemStates( + timelineItems: List, + ) { + val newTimelineItemStates = ArrayList() + for (index in diffCache.indices().reversed()) { + val cacheItem = diffCache.get(index) + if (cacheItem == null) { + buildAndCacheItem(timelineItems, index)?.also { timelineItemState -> + newTimelineItemStates.add(timelineItemState) + } + } else { + newTimelineItemStates.add(cacheItem) + } + } + _timelineItems.emit(newTimelineItemStates.toPersistentList()) + } + + private fun buildAndCacheItem( + timelineItems: List, + index: Int, + ): MediaItem? { + val timelineItem = + when (val currentTimelineItem = timelineItems[index]) { + is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem) + is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem) + MatrixTimelineItem.Other -> null + } + diffCache[index] = timelineItem + return timelineItem + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/VirtualItemFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/VirtualItemFactory.kt new file mode 100644 index 0000000000..f8a952cbd4 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/VirtualItemFactory.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.dateformatter.api.DaySeparatorFormatter +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem +import javax.inject.Inject + +interface VirtualItemFactory { + fun create(timelineItem: MatrixTimelineItem.Virtual): MediaItem? +} + +@ContributesBinding(AppScope::class) +class DefaultVirtualItemFactory @Inject constructor( + private val daySeparatorFormatter: DaySeparatorFormatter, +) : VirtualItemFactory { + override fun create(timelineItem: MatrixTimelineItem.Virtual): MediaItem? { + return when (val virtual = timelineItem.virtual) { + is VirtualTimelineItem.DayDivider -> MediaItem.DateSeparator( + id = timelineItem.uniqueId, + formattedDate = daySeparatorFormatter.format(virtual.timestamp) + ) + VirtualTimelineItem.LastForwardIndicator -> null + is VirtualTimelineItem.LoadingIndicator -> MediaItem.LoadingIndicator( + id = timelineItem.uniqueId, + direction = virtual.direction, + timestamp = virtual.timestamp + ) + VirtualTimelineItem.ReadMarker -> null + VirtualTimelineItem.RoomBeginning -> null + VirtualTimelineItem.TypingNotification -> null + } + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/diff/TimelineMediaItemsCacheInvalidator.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/diff/TimelineMediaItemsCacheInvalidator.kt new file mode 100644 index 0000000000..b7e6d51913 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/diff/TimelineMediaItemsCacheInvalidator.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery.diff + +import io.element.android.libraries.androidutils.diff.DefaultDiffCacheInvalidator +import io.element.android.libraries.androidutils.diff.DiffCacheInvalidator +import io.element.android.libraries.androidutils.diff.MutableDiffCache +import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem + +/** + * [DiffCacheInvalidator] implementation for [MediaItem]. + * It uses [DefaultDiffCacheInvalidator] and invalidate the cache around the updated item so that those items are computed again. + * This is needed because a timeline item is computed based on the previous and next items. + */ +internal class TimelineMediaItemsCacheInvalidator : DiffCacheInvalidator { + private val delegate = DefaultDiffCacheInvalidator() + + override fun onChanged(position: Int, count: Int, cache: MutableDiffCache) { + delegate.onChanged(position, count, cache) + } + + override fun onMoved(fromPosition: Int, toPosition: Int, cache: MutableDiffCache) { + delegate.onMoved(fromPosition, toPosition, cache) + } + + override fun onInserted(position: Int, count: Int, cache: MutableDiffCache) { + cache.invalidateAround(position) + delegate.onInserted(position, count, cache) + } + + override fun onRemoved(position: Int, count: Int, cache: MutableDiffCache) { + cache.invalidateAround(position) + delegate.onRemoved(position, count, cache) + } +} + +/** + * Invalidate the cache around the given position. + * It invalidates the previous and next items. + */ +private fun MutableDiffCache<*>.invalidateAround(position: Int) { + if (position > 0) { + set(position - 1, null) + } + if (position < indices().last) { + set(position + 1, null) + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryRootNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryRootNode.kt new file mode 100644 index 0000000000..1476cef64a --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryRootNode.kt @@ -0,0 +1,139 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery.root + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import com.bumble.appyx.navmodel.backstack.BackStack +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.architecture.BackstackWithOverlayBox +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.architecture.overlay.Overlay +import io.element.android.libraries.architecture.overlay.operation.hide +import io.element.android.libraries.architecture.overlay.operation.show +import io.element.android.libraries.di.RoomScope +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.MediaGalleryEntryPoint +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint +import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryNode +import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem +import io.element.android.libraries.mediaviewer.impl.gallery.eventId +import io.element.android.libraries.mediaviewer.impl.gallery.mediaInfo +import io.element.android.libraries.mediaviewer.impl.gallery.mediaSource +import io.element.android.libraries.mediaviewer.impl.gallery.thumbnailSource +import kotlinx.parcelize.Parcelize + +@ContributesNode(RoomScope::class) +class MediaGalleryRootNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val mediaViewerEntryPoint: MediaViewerEntryPoint +) : BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.Root, + savedStateMap = buildContext.savedStateMap, + ), + overlay = Overlay( + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +) { + sealed interface NavTarget : Parcelable { + @Parcelize + data object Root : NavTarget + + @Parcelize + data class MediaViewer( + val eventId: EventId?, + val mediaInfo: MediaInfo, + val mediaSource: MediaSource, + val thumbnailSource: MediaSource?, + ) : NavTarget + } + + private fun onDone() { + plugins().forEach { + it.onDone() + } + } + + private fun onViewInTimeline(eventId: EventId) { + plugins().forEach { + it.onViewInTimeline(eventId) + } + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.Root -> { + val callback = object : MediaGalleryNode.Callback { + override fun onDone() { + this@MediaGalleryRootNode.onDone() + } + + override fun onViewInTimeline(eventId: EventId) { + this@MediaGalleryRootNode.onViewInTimeline(eventId) + } + + override fun onItemClick(item: MediaItem.Event) { + overlay.show( + NavTarget.MediaViewer( + eventId = item.eventId(), + mediaInfo = item.mediaInfo(), + mediaSource = item.mediaSource(), + thumbnailSource = item.thumbnailSource(), + ) + ) + } + } + createNode(buildContext = buildContext, plugins = listOf(callback)) + } + is NavTarget.MediaViewer -> { + val callback = object : MediaViewerEntryPoint.Callback { + override fun onDone() { + overlay.hide() + } + + override fun onViewInTimeline(eventId: EventId) { + this@MediaGalleryRootNode.onViewInTimeline(eventId) + } + } + mediaViewerEntryPoint.nodeBuilder(this, buildContext) + .params( + MediaViewerEntryPoint.Params( + eventId = navTarget.eventId, + mediaInfo = navTarget.mediaInfo, + mediaSource = navTarget.mediaSource, + thumbnailSource = navTarget.thumbnailSource, + canShowInfo = true, + canDownload = true, + canShare = true, + ) + ) + .callback(callback) + .build() + } + } + } + + @Composable + override fun View(modifier: Modifier) { + BackstackWithOverlayBox(modifier) + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/DateItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/DateItemView.kt new file mode 100644 index 0000000000..7cf387a8cc --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/DateItemView.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem + +@Composable +fun DateItemView( + item: MediaItem.DateSeparator, + modifier: Modifier = Modifier, +) { + Text( + modifier = modifier + .fillMaxWidth() + .padding(12.dp), + text = item.formattedDate, + textAlign = TextAlign.Center, + style = ElementTheme.typography.fontBodyMdMedium, + color = ElementTheme.colors.textPrimary, + ) +} + +@PreviewsDayNight +@Composable +internal fun PreviewDateItemView( + @PreviewParameter(MediaItemDateSeparatorProvider::class) date: MediaItem.DateSeparator, +) = ElementPreview { + DateItemView(date) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/FileItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/FileItemView.kt new file mode 100644 index 0000000000..99ff456296 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/FileItemView.kt @@ -0,0 +1,183 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE 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.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.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.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.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.IconButton +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem + +@Composable +fun FileItemView( + file: MediaItem.File, + onClick: () -> Unit, + onShareClick: () -> Unit, + onDownloadClick: () -> Unit, + onInfoClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(top = 20.dp, start = 16.dp, end = 16.dp), + ) { + FilenameRow( + file = file, + onClick = onClick, + ) + val caption = file.mediaInfo.caption + if (caption != null) { + Spacer(modifier = Modifier.height(16.dp)) + Caption(caption) + } + Spacer(modifier = Modifier.height(16.dp)) + ActionIconsRow( + onShareClick = onShareClick, + onDownloadClick = onDownloadClick, + onInfoClick = onInfoClick, + ) + HorizontalDivider() + } +} + +@Composable +private fun FilenameRow( + file: MediaItem.File, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background( + color = ElementTheme.colors.bgSubtleSecondary, + shape = RoundedCornerShape(12.dp), + ) + .clickable { onClick() } + .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, + ) + } + } +} + +@Composable +private fun Caption(caption: String) { + Text( + modifier = Modifier.fillMaxWidth(), + text = caption, + maxLines = 5, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyLgRegular, + color = ElementTheme.colors.textPrimary, + ) +} + +@Composable +private fun ActionIconsRow( + onShareClick: () -> Unit, + onDownloadClick: () -> Unit, + onInfoClick: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + IconButton( + onClick = onShareClick, + ) { + Icon( + imageVector = CompoundIcons.ShareAndroid(), + contentDescription = null, + ) + } + IconButton( + onClick = onDownloadClick, + ) { + Icon( + imageVector = CompoundIcons.Download(), + contentDescription = null, + ) + } + IconButton( + onClick = onInfoClick, + ) { + Icon( + imageVector = CompoundIcons.Info(), + contentDescription = null, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun FileItemViewPreview( + @PreviewParameter(MediaItemFileProvider::class) file: MediaItem.File, +) = ElementPreview { + FileItemView( + file = file, + onClick = {}, + onShareClick = {}, + onDownloadClick = {}, + onInfoClick = {}, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/ImageItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/ImageItemView.kt new file mode 100644 index 0000000000..892b22a951 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/ImageItemView.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery.ui + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalInspectionMode +import coil.compose.AsyncImage +import coil.compose.AsyncImagePainter +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ImageItemView( + image: MediaItem.Image, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val bgColor = if (LocalInspectionMode.current) { + ElementTheme.colors.bgDecorative1 + } else { + Color.Transparent + } + Box( + modifier = modifier + .aspectRatio(1f) + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .background(bgColor), + ) { + var isLoaded by remember { mutableStateOf(false) } + AsyncImage( + modifier = Modifier + .fillMaxWidth() + .then(if (isLoaded) Modifier.background(Color.White) else Modifier), + model = image.thumbnailMediaRequestData, + contentScale = ContentScale.Crop, + alignment = Alignment.Center, + contentDescription = null, + onState = { isLoaded = it is AsyncImagePainter.State.Success }, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun ImageItemViewPreview() = ElementPreview { + ImageItemView( + image = anImage(), + onClick = {}, + onLongClick = {}, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemDateSeparatorProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemDateSeparatorProvider.kt new file mode 100644 index 0000000000..2d7c3d50ab --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemDateSeparatorProvider.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery.ui + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem + +class MediaItemDateSeparatorProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aDate(), + aDate(formattedDate = "A long date that should be truncated"), + ) +} + +fun aDate( + id: UniqueId = UniqueId("dateId"), + formattedDate: String = "October 2024", +): MediaItem.DateSeparator { + return MediaItem.DateSeparator( + id = id, + formattedDate = formattedDate, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemFileProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemFileProvider.kt new file mode 100644 index 0000000000..f5197e590d --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemFileProvider.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery.ui + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.core.preview.loremIpsum +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo +import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem + +class MediaItemFileProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aFile(), + aFile( + filename = "A long filename that should be truncated.jpg", + caption = "A caption", + ), + aFile( + caption = loremIpsum, + ), + ) +} + +fun aFile( + id: UniqueId = UniqueId("fileId"), + filename: String = "filename", + caption: String? = null, +): MediaItem.File { + return MediaItem.File( + id = id, + eventId = null, + mediaInfo = aPdfMediaInfo( + filename = filename, + caption = caption, + ), + mediaSource = MediaSource(""), + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemImageProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemImageProvider.kt new file mode 100644 index 0000000000..dc124cb5c7 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemImageProvider.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery.ui + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.mediaviewer.api.anImageMediaInfo +import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem + +fun anImage( + eventId: EventId? = null, + senderId: UserId? = null, +): MediaItem.Image { + return MediaItem.Image( + id = UniqueId("imageId"), + eventId = eventId, + mediaInfo = anImageMediaInfo( + senderId = senderId, + ), + mediaSource = MediaSource(""), + thumbnailSource = null, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVideoProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVideoProvider.kt new file mode 100644 index 0000000000..1db99c7e31 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVideoProvider.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery.ui + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo +import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem + +class MediaItemVideoProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aVideo(), + aVideo( + duration = null, + ), + ) +} + +fun aVideo( + id: UniqueId = UniqueId("videoId"), + mediaSource: MediaSource = MediaSource(""), + duration: String? = "1:23", +): MediaItem.Video { + return MediaItem.Video( + id = id, + eventId = null, + mediaInfo = aVideoMediaInfo(), + mediaSource = mediaSource, + thumbnailSource = null, + duration = duration, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt new file mode 100644 index 0000000000..e6d67fb01a --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt @@ -0,0 +1,116 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery.ui + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.compose.AsyncImagePainter +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.designsystem.theme.components.Text +import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun VideoItemView( + video: MediaItem.Video, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val bgColor = if (LocalInspectionMode.current) { + ElementTheme.colors.bgDecorative2 + } else { + Color.Transparent + } + Box( + modifier = modifier + .aspectRatio(1f) + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .background(bgColor), + ) { + var isLoaded by remember { mutableStateOf(false) } + AsyncImage( + modifier = Modifier + .fillMaxWidth() + .then(if (isLoaded) Modifier.background(Color.White) else Modifier), + model = video.thumbnailMediaRequestData, + contentScale = ContentScale.Crop, + alignment = Alignment.Center, + contentDescription = null, + onState = { isLoaded = it is AsyncImagePainter.State.Success }, + ) + VideoInfoRow( + video = video, + modifier = Modifier.align(Alignment.BottomStart) + ) + } +} + +@Composable +private fun VideoInfoRow( + video: MediaItem.Video, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = CompoundIcons.VideoCallSolid(), + contentDescription = null + ) + if (video.duration != null) { + Spacer(Modifier.weight(1f)) + Text( + text = video.duration, + style = ElementTheme.typography.fontBodySmMedium, + color = ElementTheme.colors.textPrimary, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun VideoItemViewPreview( + @PreviewParameter(MediaItemVideoProvider::class) video: MediaItem.Video, +) = ElementPreview { + VideoItemView( + video = video, + onClick = {}, + onLongClick = {}, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt index 8b5163cf6c..62706f120e 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt @@ -18,6 +18,7 @@ import io.element.android.libraries.androidutils.filesize.FileSizeFormatter import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.media.MediaFile import io.element.android.libraries.matrix.api.media.toFile import io.element.android.libraries.mediaviewer.api.MediaInfo @@ -41,7 +42,9 @@ class AndroidLocalMediaFactory @Inject constructor( name = mediaInfo.filename, caption = mediaInfo.caption, formattedFileSize = mediaInfo.formattedFileSize, + senderId = mediaInfo.senderId, senderName = mediaInfo.senderName, + senderAvatar = mediaInfo.senderAvatar, dateSent = mediaInfo.dateSent, ) @@ -56,7 +59,9 @@ class AndroidLocalMediaFactory @Inject constructor( name = name, caption = null, formattedFileSize = formattedFileSize, + senderId = null, senderName = null, + senderAvatar = null, dateSent = null, ) @@ -66,7 +71,9 @@ class AndroidLocalMediaFactory @Inject constructor( name: String?, caption: String?, formattedFileSize: String?, + senderId: UserId?, senderName: String?, + senderAvatar: String?, dateSent: String?, ): LocalMedia { val resolvedMimeType = mimeType ?: context.getMimeType(uri) ?: MimeTypes.OctetStream @@ -81,7 +88,9 @@ class AndroidLocalMediaFactory @Inject constructor( caption = caption, formattedFileSize = fileSize, fileExtension = fileExtension, + senderId = senderId, senderName = senderName, + senderAvatar = senderAvatar, dateSent = dateSent, ) ) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt index ac2714584c..5b85cd2b9f 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt @@ -7,10 +7,14 @@ package io.element.android.libraries.mediaviewer.impl.viewer +import io.element.android.libraries.matrix.api.core.EventId + sealed interface MediaViewerEvents { data object SaveOnDisk : MediaViewerEvents data object Share : MediaViewerEvents data object OpenWith : MediaViewerEvents data object RetryLoading : MediaViewerEvents data object ClearLoadingError : MediaViewerEvents + data class ViewInTimeline(val eventId: EventId) : MediaViewerEvents + data class Delete(val eventId: EventId) : MediaViewerEvents } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNavigator.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNavigator.kt new file mode 100644 index 0000000000..07fa0ec15d --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNavigator.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.viewer + +import io.element.android.libraries.matrix.api.core.EventId + +interface MediaViewerNavigator { + fun onViewInTimelineClick(eventId: EventId) + fun onItemDeleted() +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt index 83c7c1aca7..9a9af5ee63 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt @@ -19,14 +19,16 @@ import io.element.android.anvilannotations.ContributesNode import io.element.android.compound.theme.ForcedDarkElementTheme import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint @ContributesNode(RoomScope::class) -open class MediaViewerNode @AssistedInject constructor( +class MediaViewerNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, presenterFactory: MediaViewerPresenter.Factory, -) : Node(buildContext, plugins = plugins) { +) : Node(buildContext, plugins = plugins), + MediaViewerNavigator { private val inputs = inputs() private fun onDone() { @@ -35,7 +37,20 @@ open class MediaViewerNode @AssistedInject constructor( } } - private val presenter = presenterFactory.create(inputs) + override fun onViewInTimelineClick(eventId: EventId) { + plugins().forEach { + it.onViewInTimeline(eventId) + } + } + + override fun onItemDeleted() { + onDone() + } + + private val presenter = presenterFactory.create( + inputs = inputs, + navigator = this, + ) @Composable override fun View(modifier: Modifier) { diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt index 068fb02b0f..a2201aec65 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt @@ -11,9 +11,11 @@ import android.content.ActivityNotFoundException import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -25,8 +27,13 @@ import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState +import io.element.android.libraries.matrix.api.core.EventId 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.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther +import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn +import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory @@ -38,6 +45,8 @@ import io.element.android.libraries.androidutils.R as UtilsR class MediaViewerPresenter @AssistedInject constructor( @Assisted private val inputs: MediaViewerEntryPoint.Params, + @Assisted private val navigator: MediaViewerNavigator, + private val room: MatrixRoom, private val localMediaFactory: LocalMediaFactory, private val mediaLoader: MatrixMediaLoader, private val localMediaActions: LocalMediaActions, @@ -45,7 +54,10 @@ class MediaViewerPresenter @AssistedInject constructor( ) : Presenter { @AssistedFactory interface Factory { - fun create(inputs: MediaViewerEntryPoint.Params): MediaViewerPresenter + fun create( + inputs: MediaViewerEntryPoint.Params, + navigator: MediaViewerNavigator, + ): MediaViewerPresenter } @Composable @@ -67,6 +79,15 @@ class MediaViewerPresenter @AssistedInject constructor( } } + val syncUpdateFlow = room.syncUpdateFlow.collectAsState() + val canDelete by produceState(false, syncUpdateFlow.value) { + value = when (inputs.mediaInfo.senderId) { + null -> false + room.sessionId -> room.canRedactOwn().getOrElse { false } && inputs.eventId != null + else -> room.canRedactOther().getOrElse { false } && inputs.eventId != null + } + } + fun handleEvents(mediaViewerEvents: MediaViewerEvents) { when (mediaViewerEvents) { MediaViewerEvents.RetryLoading -> loadMediaTrigger++ @@ -74,16 +95,23 @@ class MediaViewerPresenter @AssistedInject constructor( MediaViewerEvents.SaveOnDisk -> coroutineScope.saveOnDisk(localMedia.value) MediaViewerEvents.Share -> coroutineScope.share(localMedia.value) MediaViewerEvents.OpenWith -> coroutineScope.open(localMedia.value) + is MediaViewerEvents.Delete -> coroutineScope.delete(mediaViewerEvents.eventId) + is MediaViewerEvents.ViewInTimeline -> { + navigator.onViewInTimelineClick(mediaViewerEvents.eventId) + } } } return MediaViewerState( + eventId = inputs.eventId, mediaInfo = inputs.mediaInfo, thumbnailSource = inputs.thumbnailSource, downloadedMedia = localMedia.value, snackbarMessage = snackbarMessage, + canShowInfo = inputs.canShowInfo, canDownload = inputs.canDownload, canShare = inputs.canShare, + canDelete = canDelete, eventSink = ::handleEvents ) } @@ -126,6 +154,17 @@ class MediaViewerPresenter @AssistedInject constructor( } } + private fun CoroutineScope.delete(eventId: EventId) = launch { + room.liveTimeline.redactEvent(eventId.toEventOrTransactionId(), null) + .onFailure { + val snackbarMessage = SnackbarMessage(CommonStrings.error_unknown) + snackbarDispatcher.post(snackbarMessage) + } + .onSuccess { + navigator.onItemDeleted() + } + } + private fun CoroutineScope.share(localMedia: AsyncData) = launch { if (localMedia is AsyncData.Success) { localMediaActions.share(localMedia.data) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt index 94d6653241..bdd59c5426 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt @@ -9,16 +9,20 @@ package io.element.android.libraries.mediaviewer.impl.viewer import io.element.android.libraries.architecture.AsyncData 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.mediaviewer.api.MediaInfo import io.element.android.libraries.mediaviewer.api.local.LocalMedia data class MediaViewerState( + val eventId: EventId?, val mediaInfo: MediaInfo, val thumbnailSource: MediaSource?, val downloadedMedia: AsyncData, val snackbarMessage: SnackbarMessage?, + val canShowInfo: Boolean, val canDownload: Boolean, val canShare: Boolean, + val canDelete: Boolean, val eventSink: (MediaViewerEvents) -> Unit, ) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt index 6c7a9fb704..003e079b99 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt @@ -30,10 +30,10 @@ open class MediaViewerStateProvider : PreviewParameterProvider caption = "A caption", ).let { aMediaViewerState( - AsyncData.Success( + downloadedMedia = AsyncData.Success( LocalMedia(Uri.EMPTY, it) ), - it, + mediaInfo = it, ) }, aVideoMediaInfo( @@ -42,50 +42,51 @@ open class MediaViewerStateProvider : PreviewParameterProvider caption = "A caption", ).let { aMediaViewerState( - AsyncData.Success( + downloadedMedia = AsyncData.Success( LocalMedia(Uri.EMPTY, it) ), - it, + mediaInfo = it, ) }, aPdfMediaInfo().let { aMediaViewerState( - AsyncData.Success( + downloadedMedia = AsyncData.Success( LocalMedia(Uri.EMPTY, it) ), - it, + mediaInfo = it, ) }, aMediaViewerState( - AsyncData.Loading(), - anApkMediaInfo(), + downloadedMedia = AsyncData.Loading(), + mediaInfo = anApkMediaInfo(), ), anApkMediaInfo().let { aMediaViewerState( - AsyncData.Success( + downloadedMedia = AsyncData.Success( LocalMedia(Uri.EMPTY, it) ), - it, + mediaInfo = it, ) }, aMediaViewerState( - AsyncData.Loading(), - anAudioMediaInfo(), + downloadedMedia = AsyncData.Loading(), + mediaInfo = anAudioMediaInfo(), ), anAudioMediaInfo().let { aMediaViewerState( - AsyncData.Success( + downloadedMedia = AsyncData.Success( LocalMedia(Uri.EMPTY, it) ), - it, + mediaInfo = it, ) }, anImageMediaInfo().let { aMediaViewerState( - AsyncData.Success( + downloadedMedia = AsyncData.Success( LocalMedia(Uri.EMPTY, it) ), - it, + mediaInfo = it, + canShowInfo = false, canDownload = false, canShare = false, ) @@ -96,15 +97,19 @@ open class MediaViewerStateProvider : PreviewParameterProvider fun aMediaViewerState( downloadedMedia: AsyncData = AsyncData.Uninitialized, mediaInfo: MediaInfo = anImageMediaInfo(), + canShowInfo: Boolean = true, canDownload: Boolean = true, canShare: Boolean = true, eventSink: (MediaViewerEvents) -> Unit = {}, ) = MediaViewerState( + eventId = null, mediaInfo = mediaInfo, thumbnailSource = null, downloadedMedia = downloadedMedia, snackbarMessage = null, + canShowInfo = canShowInfo, canDownload = canDownload, canShare = canShare, + canDelete = true, eventSink = eventSink, ) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index 3a468eb0f5..f5fae4d3bb 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -68,6 +68,9 @@ 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 import io.element.android.libraries.mediaviewer.impl.local.LocalMediaView import io.element.android.libraries.mediaviewer.impl.local.PlayableState import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState @@ -92,6 +95,7 @@ fun MediaViewerView( val defaultBottomPaddingInPixels = if (LocalInspectionMode.current) 303 else 0 var bottomPaddingInPixels by remember { mutableIntStateOf(defaultBottomPaddingInPixels) } BackHandler { onBackClick() } + var mediaBottomSheetState by remember { mutableStateOf(MediaBottomSheetState.Hidden) } Scaffold( modifier, containerColor = Color.Transparent, @@ -121,7 +125,16 @@ fun MediaViewerView( mimeType = state.mediaInfo.mimeType, senderName = state.mediaInfo.senderName, dateSent = state.mediaInfo.dateSent, + canShowInfo = state.canShowInfo, onBackClick = onBackClick, + onInfoClick = { + mediaBottomSheetState = MediaBottomSheetState.MediaDetailsBottomSheetState( + eventId = state.eventId, + canDelete = state.canDelete, + mediaInfo = state.mediaInfo, + thumbnailSource = state.thumbnailSource, + ) + }, eventSink = state.eventSink ) MediaViewerBottomBar( @@ -133,6 +146,40 @@ fun MediaViewerView( } } } + when (val bottomSheetState = mediaBottomSheetState) { + MediaBottomSheetState.Hidden -> Unit + is MediaBottomSheetState.MediaDetailsBottomSheetState -> { + MediaDetailsBottomSheet( + state = bottomSheetState, + onViewInTimeline = { + mediaBottomSheetState = MediaBottomSheetState.Hidden + state.eventSink(MediaViewerEvents.ViewInTimeline(it)) + }, + onDelete = { eventId -> + mediaBottomSheetState = MediaBottomSheetState.MediaDeleteConfirmationState( + eventId = eventId, + mediaInfo = state.mediaInfo, + thumbnailSource = state.thumbnailSource, + ) + }, + onDismiss = { + mediaBottomSheetState = MediaBottomSheetState.Hidden + }, + ) + } + is MediaBottomSheetState.MediaDeleteConfirmationState -> { + MediaDeleteConfirmationBottomSheet( + state = bottomSheetState, + onDelete = { + mediaBottomSheetState = MediaBottomSheetState.Hidden + state.eventSink(MediaViewerEvents.Delete(it)) + }, + onDismiss = { + mediaBottomSheetState = MediaBottomSheetState.Hidden + }, + ) + } + } } @Composable @@ -283,7 +330,9 @@ private fun MediaViewerTopBar( mimeType: String, senderName: String?, dateSent: String?, + canShowInfo: Boolean, onBackClick: () -> Unit, + onInfoClick: () -> Unit, eventSink: (MediaViewerEvents) -> Unit, ) { TopAppBar( @@ -354,7 +403,17 @@ private fun MediaViewerTopBar( ) } } - // TODO Add action to open infos. + if (canShowInfo) { + IconButton( + onClick = onInfoClick, + enabled = actionsEnabled, + ) { + Icon( + imageVector = CompoundIcons.Info(), + contentDescription = null, + ) + } + } } ) } diff --git a/libraries/mediaviewer/impl/src/main/res/values/localazy.xml b/libraries/mediaviewer/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..992b8edd88 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values/localazy.xml @@ -0,0 +1,16 @@ + + + "This file will be removed from the room and members won’t have access to it." + "Delete file?" + "Images and videos uploaded to this room will be shown here." + "No media uploaded yet" + "Loading files…" + "Loading media…" + "Files" + "Media" + "Media and files" + "File format" + "File name" + "Uploaded by" + "Uploaded on" + diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeEventItemFactory.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeEventItemFactory.kt new file mode 100644 index 0000000000..6462204721 --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeEventItemFactory.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem + +class FakeEventItemFactory : EventItemFactory { + override fun create(currentTimelineItem: MatrixTimelineItem.Event): MediaItem.Event? { + return null + } +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaGalleryNavigator.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaGalleryNavigator.kt new file mode 100644 index 0000000000..6633fcbce1 --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaGalleryNavigator.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeMediaGalleryNavigator( + private val onViewInTimelineClickLambda: (EventId) -> Unit = { lambdaError() } +) : MediaGalleryNavigator { + override fun onViewInTimelineClick(eventId: EventId) { + onViewInTimelineClickLambda(eventId) + } +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaItemsPostProcessor.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaItemsPostProcessor.kt new file mode 100644 index 0000000000..637c60d57d --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaItemsPostProcessor.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +import io.element.android.libraries.architecture.AsyncData +import kotlinx.collections.immutable.ImmutableList + +class FakeMediaItemsPostProcessor : MediaItemsPostProcessor { + override fun process(mediaItems: AsyncData>, predicate: (MediaItem.Event) -> Boolean): AsyncData> { + return mediaItems + } +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeTimelineMediaItemsFactory.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeTimelineMediaItemsFactory.kt new file mode 100644 index 0000000000..618ba855ad --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeTimelineMediaItemsFactory.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.tests.testutils.lambda.lambdaError +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +class FakeTimelineMediaItemsFactory( + private val replaceWithLambda: (List) -> Unit = { lambdaError() }, + private val onCanPaginateLambda: () -> Unit = { lambdaError() } +) : TimelineMediaItemsFactory { + override val timelineItems: Flow> + get() = flowOf(emptyList().toImmutableList()) + + override suspend fun replaceWith(timelineItems: List) { + replaceWithLambda(timelineItems) + } + + override suspend fun onCanPaginate() { + onCanPaginateLambda() + } +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeVirtualItemFactory.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeVirtualItemFactory.kt new file mode 100644 index 0000000000..40e2780a41 --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeVirtualItemFactory.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem + +class FakeVirtualItemFactory : VirtualItemFactory { + override fun create(timelineItem: MatrixTimelineItem.Virtual): MediaItem? { + return null + } +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt new file mode 100644 index 0000000000..2d6a07e477 --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt @@ -0,0 +1,261 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +import android.net.Uri +import com.google.common.truth.Truth.assertThat +import io.element.android.features.networkmonitor.test.FakeNetworkMonitor +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_ROOM_NAME +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_ID_2 +import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState +import io.element.android.libraries.mediaviewer.impl.gallery.ui.anImage +import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions +import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory +import io.element.android.tests.testutils.WarmUpRule +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.test.runTest +import org.junit.Rule +import org.junit.Test + +class MediaGalleryPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + private val mockMediaUri: Uri = mockk("localMediaUri") + private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri) + + @Test + fun `present - initial state`() = runTest { + val onViewInTimelineClickLambda = lambdaRecorder { } + val navigator = FakeMediaGalleryNavigator( + onViewInTimelineClickLambda = onViewInTimelineClickLambda, + ) + val presenter = createMediaGalleryPresenter( + navigator = navigator, + room = FakeMatrixRoom( + displayName = A_ROOM_NAME, + mediaTimelineResult = { Result.success(FakeTimeline()) }, + ) + ) + presenter.test { + skipItems(2) + val initialState = awaitItem() + assertThat(initialState.mode).isEqualTo(MediaGalleryMode.Images) + assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) + assertThat(initialState.roomName).isEqualTo(A_ROOM_NAME) + assertThat(initialState.imageItems.dataOrNull()).isEmpty() + assertThat(initialState.fileItems.dataOrNull()).isEmpty() + assertThat(initialState.snackbarMessage).isNull() + } + } + + @Test + fun `present - change mode`() = runTest { + val onViewInTimelineClickLambda = lambdaRecorder { } + val navigator = FakeMediaGalleryNavigator( + onViewInTimelineClickLambda = onViewInTimelineClickLambda, + ) + val presenter = createMediaGalleryPresenter( + navigator = navigator, + room = FakeMatrixRoom( + displayName = A_ROOM_NAME, + mediaTimelineResult = { Result.success(FakeTimeline()) }, + ) + ) + presenter.test { + skipItems(2) + val initialState = awaitItem() + assertThat(initialState.mode).isEqualTo(MediaGalleryMode.Images) + initialState.eventSink(MediaGalleryEvents.ChangeMode(MediaGalleryMode.Files)) + val state = awaitItem() + assertThat(state.mode).isEqualTo(MediaGalleryMode.Files) + state.eventSink(MediaGalleryEvents.ChangeMode(MediaGalleryMode.Images)) + val imageModeState = awaitItem() + assertThat(imageModeState.mode).isEqualTo(MediaGalleryMode.Images) + } + } + + @Test + fun `present - bottom sheet state - own message and can delete own`() = runTest { + `present - bottom sheet state - own message`(canDeleteOwn = true) + } + + @Test + fun `present - bottom sheet state - own message and cannot delete own`() = runTest { + `present - bottom sheet state - own message`(canDeleteOwn = false) + } + + private suspend fun `present - bottom sheet state - own message`(canDeleteOwn: Boolean) { + val presenter = createMediaGalleryPresenter( + room = FakeMatrixRoom( + sessionId = A_USER_ID, + displayName = A_ROOM_NAME, + mediaTimelineResult = { Result.success(FakeTimeline()) }, + canRedactOwnResult = { Result.success(canDeleteOwn) } + ) + ) + presenter.test { + skipItems(2) + val initialState = awaitItem() + assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) + val item = anImage( + eventId = AN_EVENT_ID, + senderId = A_USER_ID, + ) + initialState.eventSink(MediaGalleryEvents.OpenInfo(item)) + val state = awaitItem() + assertThat(state.mediaBottomSheetState).isEqualTo( + MediaBottomSheetState.MediaDetailsBottomSheetState( + eventId = AN_EVENT_ID, + canDelete = canDeleteOwn, + mediaInfo = item.mediaInfo, + thumbnailSource = item.mediaSource, + ) + ) + // Close the bottom sheet + state.eventSink(MediaGalleryEvents.CloseBottomSheet) + val closedState = awaitItem() + assertThat(closedState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) + } + } + + @Test + fun `present - bottom sheet state - other message and can delete other`() = runTest { + `present - bottom sheet state - other message`(canDeleteOther = true) + } + + @Test + fun `present - bottom sheet state - other message and cannot delete other`() = runTest { + `present - bottom sheet state - other message`(canDeleteOther = false) + } + + private suspend fun `present - bottom sheet state - other message`(canDeleteOther: Boolean) { + val presenter = createMediaGalleryPresenter( + room = FakeMatrixRoom( + sessionId = A_USER_ID, + displayName = A_ROOM_NAME, + mediaTimelineResult = { Result.success(FakeTimeline()) }, + canRedactOtherResult = { Result.success(canDeleteOther) } + ) + ) + presenter.test { + skipItems(2) + val initialState = awaitItem() + assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) + val item = anImage( + eventId = AN_EVENT_ID, + senderId = A_USER_ID_2, + ) + initialState.eventSink(MediaGalleryEvents.OpenInfo(item)) + val state = awaitItem() + assertThat(state.mediaBottomSheetState).isEqualTo( + MediaBottomSheetState.MediaDetailsBottomSheetState( + eventId = AN_EVENT_ID, + canDelete = canDeleteOther, + mediaInfo = item.mediaInfo, + thumbnailSource = item.mediaSource, + ) + ) + // Close the bottom sheet + state.eventSink(MediaGalleryEvents.CloseBottomSheet) + val closedState = awaitItem() + assertThat(closedState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) + } + } + + @Test + fun `present - delete bottom sheet`() = runTest { + val presenter = createMediaGalleryPresenter( + room = FakeMatrixRoom( + displayName = A_ROOM_NAME, + mediaTimelineResult = { Result.success(FakeTimeline()) }, + ) + ) + presenter.test { + skipItems(2) + val initialState = awaitItem() + // Delete bottom sheet + val item = anImage() + initialState.eventSink(MediaGalleryEvents.ConfirmDelete(AN_EVENT_ID, item.mediaInfo, item.thumbnailSource)) + val deleteState = awaitItem() + assertThat(deleteState.mediaBottomSheetState).isEqualTo( + MediaBottomSheetState.MediaDeleteConfirmationState( + eventId = AN_EVENT_ID, + mediaInfo = item.mediaInfo, + thumbnailSource = item.thumbnailSource, + ) + ) + // Close the bottom sheet + deleteState.eventSink(MediaGalleryEvents.CloseBottomSheet) + val deleteClosedState = awaitItem() + assertThat(deleteClosedState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) + } + } + + @Test + fun `present - view in timeline invokes the navigator`() = runTest { + val onViewInTimelineClickLambda = lambdaRecorder { } + val navigator = FakeMediaGalleryNavigator( + onViewInTimelineClickLambda = onViewInTimelineClickLambda, + ) + val presenter = createMediaGalleryPresenter( + room = FakeMatrixRoom( + mediaTimelineResult = { Result.success(FakeTimeline()) }, + ), + navigator = navigator, + ) + presenter.test { + skipItems(2) + val initialState = awaitItem() + initialState.eventSink(MediaGalleryEvents.ViewInTimeline(AN_EVENT_ID)) + onViewInTimelineClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID)) + } + } + + private fun createMediaGalleryPresenter( + matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(), + localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(), + snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), + navigator: MediaGalleryNavigator = FakeMediaGalleryNavigator(), + room: MatrixRoom = FakeMatrixRoom( + liveTimeline = FakeTimeline(), + ), + ): MediaGalleryPresenter { + return MediaGalleryPresenter( + navigator = navigator, + room = room, + timelineProvider = MediaGalleryTimelineProvider( + room = room, + networkMonitor = FakeNetworkMonitor(), + featureFlagService = FakeFeatureFlagService(), + ), + timelineMediaItemsFactory = FakeTimelineMediaItemsFactory( + replaceWithLambda = lambdaRecorder, Unit> { _ -> }, + onCanPaginateLambda = lambdaRecorder { }, + ), + localMediaFactory = localMediaFactory, + mediaLoader = matrixMediaLoader, + localMediaActions = localMediaActions, + snackbarDispatcher = snackbarDispatcher, + mediaItemsPostProcessor = FakeMediaItemsPostProcessor(), + ) + } +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt index c341f6e751..bd9fe7d884 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt @@ -38,7 +38,9 @@ class AndroidLocalMediaFactoryTest { mimeType = MimeTypes.Jpeg, formattedFileSize = "4MB", fileExtension = "jpg", + senderId = null, senderName = A_USER_NAME, + senderAvatar = null, dateSent = "12:34" ) ) diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/FakeMediaViewerNavigator.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/FakeMediaViewerNavigator.kt new file mode 100644 index 0000000000..c07c53f8ae --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/FakeMediaViewerNavigator.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.viewer + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeMediaViewerNavigator( + private val onViewInTimelineClickLambda: (EventId) -> Unit = { lambdaError() }, + private val onItemDeletedLambda: () -> Unit = { lambdaError() }, +) : MediaViewerNavigator { + override fun onViewInTimelineClick(eventId: EventId) { + onViewInTimelineClickLambda(eventId) + } + + override fun onItemDeleted() { + onItemDeletedLambda() + } +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt index cbe334216c..aaae1d8b79 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt @@ -16,20 +16,34 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId +import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID_2 +import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader import io.element.android.libraries.matrix.test.media.aMediaSource +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.libraries.mediaviewer.api.anApkMediaInfo import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory import io.element.android.tests.testutils.WarmUpRule +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.runTest import org.junit.Rule import org.junit.Test -private val TESTED_MEDIA_INFO = anApkMediaInfo() +private val TESTED_MEDIA_INFO = anApkMediaInfo( + senderId = A_USER_ID, +) class MediaViewerPresenterTest { @get:Rule @@ -38,11 +52,133 @@ class MediaViewerPresenterTest { private val mockMediaUri: Uri = mockk("localMediaUri") private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri) + @Test + fun `present - initial state null Event`() = runTest { + val presenter = createMediaViewerPresenter( + room = FakeMatrixRoom( + canRedactOwnResult = { Result.success(true) }, + ) + ) + presenter.test { + skipItems(2) + val initialState = awaitItem() + assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) + assertThat(initialState.snackbarMessage).isNull() + assertThat(initialState.canShowInfo).isTrue() + assertThat(initialState.canDownload).isTrue() + assertThat(initialState.canShare).isTrue() + assertThat(initialState.canDelete).isFalse() + } + } + + @Test + fun `present - initial state cannot show info`() = runTest { + val presenter = createMediaViewerPresenter( + canShowInfo = false, + room = FakeMatrixRoom( + canRedactOwnResult = { Result.success(true) }, + ) + ) + presenter.test { + skipItems(2) + val initialState = awaitItem() + assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) + assertThat(initialState.snackbarMessage).isNull() + assertThat(initialState.canShowInfo).isFalse() + assertThat(initialState.canDownload).isTrue() + assertThat(initialState.canShare).isTrue() + assertThat(initialState.canDelete).isFalse() + } + } + + @Test + fun `present - initial state cannot share`() = runTest { + val presenter = createMediaViewerPresenter( + canShare = false, + room = FakeMatrixRoom( + canRedactOwnResult = { Result.success(true) }, + ) + ) + presenter.test { + skipItems(2) + val initialState = awaitItem() + assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) + assertThat(initialState.snackbarMessage).isNull() + assertThat(initialState.canShowInfo).isTrue() + assertThat(initialState.canDownload).isTrue() + assertThat(initialState.canShare).isFalse() + assertThat(initialState.canDelete).isFalse() + } + } + + @Test + fun `present - initial state cannot download`() = runTest { + val presenter = createMediaViewerPresenter( + canDownload = false, + room = FakeMatrixRoom( + canRedactOwnResult = { Result.success(true) }, + ) + ) + presenter.test { + skipItems(2) + val initialState = awaitItem() + assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) + assertThat(initialState.snackbarMessage).isNull() + assertThat(initialState.canShowInfo).isTrue() + assertThat(initialState.canDownload).isFalse() + assertThat(initialState.canShare).isTrue() + assertThat(initialState.canDelete).isFalse() + } + } + + @Test + fun `present - initial state Event`() = runTest { + val presenter = createMediaViewerPresenter( + eventId = AN_EVENT_ID, + room = FakeMatrixRoom( + canRedactOwnResult = { Result.success(true) }, + ) + ) + presenter.test { + skipItems(2) + val initialState = awaitItem() + assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) + assertThat(initialState.snackbarMessage).isNull() + assertThat(initialState.canShowInfo).isTrue() + assertThat(initialState.canDownload).isTrue() + assertThat(initialState.canShare).isTrue() + assertThat(initialState.canDelete).isTrue() + } + } + + @Test + fun `present - initial state Event from other`() = runTest { + val presenter = createMediaViewerPresenter( + eventId = AN_EVENT_ID, + room = FakeMatrixRoom( + sessionId = A_SESSION_ID_2, + canRedactOtherResult = { Result.success(false) }, + ) + ) + presenter.test { + skipItems(2) + val initialState = awaitItem() + assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) + assertThat(initialState.snackbarMessage).isNull() + assertThat(initialState.canShowInfo).isTrue() + assertThat(initialState.canDownload).isTrue() + assertThat(initialState.canShare).isTrue() + assertThat(initialState.canDelete).isFalse() + } + } + @Test fun `present - download media success scenario`() = runTest { - val matrixMediaLoader = FakeMatrixMediaLoader() - val mediaActions = FakeLocalMediaActions() - val presenter = createMediaViewerPresenter(matrixMediaLoader, mediaActions) + val presenter = createMediaViewerPresenter( + room = FakeMatrixRoom( + canRedactOwnResult = { Result.success(true) }, + ) + ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -60,10 +196,15 @@ class MediaViewerPresenterTest { @Test fun `present - check all actions`() = runTest { - val matrixMediaLoader = FakeMatrixMediaLoader() val mediaActions = FakeLocalMediaActions() val snackbarDispatcher = SnackbarDispatcher() - val presenter = createMediaViewerPresenter(matrixMediaLoader, mediaActions, snackbarDispatcher) + val presenter = createMediaViewerPresenter( + localMediaActions = mediaActions, + snackbarDispatcher = snackbarDispatcher, + room = FakeMatrixRoom( + canRedactOwnResult = { Result.success(true) }, + ) + ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -108,8 +249,12 @@ class MediaViewerPresenterTest { @Test fun `present - download media failure then retry with success scenario`() = runTest { val matrixMediaLoader = FakeMatrixMediaLoader() - val mediaActions = FakeLocalMediaActions() - val presenter = createMediaViewerPresenter(matrixMediaLoader, mediaActions) + val presenter = createMediaViewerPresenter( + matrixMediaLoader = matrixMediaLoader, + room = FakeMatrixRoom( + canRedactOwnResult = { Result.success(true) }, + ) + ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -134,18 +279,87 @@ class MediaViewerPresenterTest { } } + @Test + fun `present - delete media success scenario`() = runTest { + val redactEventLambda = lambdaRecorder> { _, _ -> + Result.success(Unit) + } + val timeline = FakeTimeline().apply { + this.redactEventLambda = redactEventLambda + } + val onItemDeletedLambda = lambdaRecorder { } + val navigator = FakeMediaViewerNavigator( + onItemDeletedLambda = onItemDeletedLambda, + ) + + val presenter = createMediaViewerPresenter( + room = FakeMatrixRoom( + liveTimeline = timeline, + canRedactOwnResult = { Result.success(true) }, + ), + mediaViewerNavigator = navigator, + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized) + assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO) + val loadingState = awaitItem() + assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java) + val successState = awaitItem() + assertThat(successState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) + successState.eventSink(MediaViewerEvents.Delete(AN_EVENT_ID)) + redactEventLambda.assertions().isCalledOnce().with( + value(AN_EVENT_ID.toEventOrTransactionId()), value(null) + ) + onItemDeletedLambda.assertions().isCalledOnce() + } + } + + @Test + fun `present - view in timeline invokes the navigator`() = runTest { + val onViewInTimelineClickLambda = lambdaRecorder { } + val navigator = FakeMediaViewerNavigator( + onViewInTimelineClickLambda = onViewInTimelineClickLambda, + ) + val presenter = createMediaViewerPresenter( + mediaViewerNavigator = navigator, + room = FakeMatrixRoom( + canRedactOwnResult = { Result.success(true) }, + ) + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized) + assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO) + val loadingState = awaitItem() + assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java) + val successState = awaitItem() + assertThat(successState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) + successState.eventSink(MediaViewerEvents.ViewInTimeline(AN_EVENT_ID)) + onViewInTimelineClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID)) + } + } + private fun createMediaViewerPresenter( - matrixMediaLoader: FakeMatrixMediaLoader, - localMediaActions: FakeLocalMediaActions, + eventId: EventId? = null, + matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(), + localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(), snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), + canShowInfo: Boolean = true, canShare: Boolean = true, canDownload: Boolean = true, + mediaViewerNavigator: MediaViewerNavigator = FakeMediaViewerNavigator(), + room: MatrixRoom = FakeMatrixRoom( + liveTimeline = FakeTimeline(), + ), ): MediaViewerPresenter { return MediaViewerPresenter( inputs = MediaViewerEntryPoint.Params( + eventId = eventId, mediaInfo = TESTED_MEDIA_INFO, mediaSource = aMediaSource(), thumbnailSource = null, + canShowInfo = canShowInfo, canShare = canShare, canDownload = canDownload, ), @@ -153,6 +367,9 @@ class MediaViewerPresenterTest { mediaLoader = matrixMediaLoader, localMediaActions = localMediaActions, snackbarDispatcher = snackbarDispatcher, + navigator = mediaViewerNavigator, + room = room, ) } } + diff --git a/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeLocalMediaFactory.kt b/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeLocalMediaFactory.kt index a0f36c6f0f..c41435afc0 100644 --- a/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeLocalMediaFactory.kt +++ b/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeLocalMediaFactory.kt @@ -37,7 +37,9 @@ class FakeLocalMediaFactory( mimeType = mimeType ?: fallbackMimeType, formattedFileSize = formattedFileSize ?: fallbackFileSize, fileExtension = fileExtensionExtractor.extractFromName(safeName), + senderId = null, senderName = null, + senderAvatar = null, dateSent = null ) return aLocalMedia(uri, mediaInfo) diff --git a/tools/localazy/config.json b/tools/localazy/config.json index a365379344..fe2f7d3e03 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -92,6 +92,13 @@ "error_no_compatible_app_found" ] }, + { + "name" : ":libraries:mediaviewer:impl", + "includeRegex" : [ + "screen\\.media_details\\..*", + "screen_media_browser_.*" + ] + }, { "name" : ":libraries:eventformatter:impl", "includeRegex" : [ From 653c1e8214e9e2699380f3ba089a7d4fbc6ec4a1 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 9 Dec 2024 17:16:29 +0100 Subject: [PATCH 02/31] Sync strings. --- .../src/main/res/values-cs/translations.xml | 9 ++++++++ .../src/main/res/values-de/translations.xml | 8 +++++++ .../src/main/res/values-el/translations.xml | 8 +++++++ .../src/main/res/values-et/translations.xml | 8 +++++++ .../src/main/res/values-fr/translations.xml | 8 +++++++ .../src/main/res/values-hu/translations.xml | 8 +++++++ .../src/main/res/values-it/translations.xml | 8 +++++++ .../src/main/res/values-ru/translations.xml | 9 ++++++++ .../src/main/res/values-sk/translations.xml | 9 ++++++++ .../src/main/res/values-cs/translations.xml | 10 +++++++++ .../src/main/res/values-de/translations.xml | 14 +++++++++++++ .../src/main/res/values-et/translations.xml | 16 ++++++++++++++ .../src/main/res/values-fr/translations.xml | 16 ++++++++++++++ .../src/main/res/values-hu/translations.xml | 16 ++++++++++++++ .../src/main/res/values-it/translations.xml | 8 +++++++ .../src/main/res/values-ru/translations.xml | 16 ++++++++++++++ .../impl/src/main/res/values/localazy.xml | 2 ++ .../src/main/res/values-cs/translations.xml | 16 -------------- .../src/main/res/values-de/translations.xml | 19 ----------------- .../src/main/res/values-el/translations.xml | 8 ------- .../src/main/res/values-et/translations.xml | 15 ------------- .../src/main/res/values-fr/translations.xml | 21 ------------------- .../src/main/res/values-hu/translations.xml | 21 ------------------- .../src/main/res/values-it/translations.xml | 13 ------------ .../src/main/res/values-ru/translations.xml | 16 -------------- .../src/main/res/values-sk/translations.xml | 9 -------- .../src/main/res/values/localazy.xml | 15 ++----------- 27 files changed, 175 insertions(+), 151 deletions(-) create mode 100644 libraries/mediaviewer/impl/src/main/res/values-cs/translations.xml create mode 100644 libraries/mediaviewer/impl/src/main/res/values-de/translations.xml create mode 100644 libraries/mediaviewer/impl/src/main/res/values-et/translations.xml create mode 100644 libraries/mediaviewer/impl/src/main/res/values-fr/translations.xml create mode 100644 libraries/mediaviewer/impl/src/main/res/values-hu/translations.xml create mode 100644 libraries/mediaviewer/impl/src/main/res/values-it/translations.xml create mode 100644 libraries/mediaviewer/impl/src/main/res/values-ru/translations.xml diff --git a/features/knockrequests/impl/src/main/res/values-cs/translations.xml b/features/knockrequests/impl/src/main/res/values-cs/translations.xml index b02fdf8595..b8c4f6f125 100644 --- a/features/knockrequests/impl/src/main/res/values-cs/translations.xml +++ b/features/knockrequests/impl/src/main/res/values-cs/translations.xml @@ -14,4 +14,13 @@ "Když někdo požádá o vstup do místnosti, uvidíte jeho žádost zde." "Žádná čekající žádost o vstup" "Žádosti o vstup" + + "%1$s +%2$d další chce vstoupit do této místnosti" + "%1$s +%2$d další chtějí vstoupit do této místnosti" + "%1$s +%2$d dalších chce vstoupit do této místnosti" + + "Zobrazit vše" + "Přijmout" + "%1$s chce vstoupit do této místnosti" + "Zobrazit" diff --git a/features/knockrequests/impl/src/main/res/values-de/translations.xml b/features/knockrequests/impl/src/main/res/values-de/translations.xml index 693b1c8882..70e43ba076 100644 --- a/features/knockrequests/impl/src/main/res/values-de/translations.xml +++ b/features/knockrequests/impl/src/main/res/values-de/translations.xml @@ -14,4 +14,12 @@ "Falls jemand um Aufnahme in den Raum bittet, können Sie dessen Anfrage hier sehen." "Keine ausstehende Beitrittsanfrage" "Beitrittsanfragen" + + "%1$s+ %2$d andere wollen diesem Chatroom beitreten" + "%1$s+ %2$d andere wollen diesem Chatroom beitreten" + + "Alles ansehen" + "Akzeptieren" + "%1$s möchte diesem Chatroom beitreten" + "Ansicht" diff --git a/features/knockrequests/impl/src/main/res/values-el/translations.xml b/features/knockrequests/impl/src/main/res/values-el/translations.xml index 794312dd93..a59b6757db 100644 --- a/features/knockrequests/impl/src/main/res/values-el/translations.xml +++ b/features/knockrequests/impl/src/main/res/values-el/translations.xml @@ -14,4 +14,12 @@ "Όταν κάποιος θα ζητήσει να συμμετάσχει στο δωμάτιο, θα μπορείς να δεις το αίτημά του εδώ." "Δεν υπάρχει εκκρεμές αίτημα συμμετοχής" "Αιτήματα συμμετοχής" + + "Οι χρήστες %1$s +%2$d ακόμη θέλουν να συμμετάσχουν σε αυτό το δωμάτιο" + "Οι χρήστες %1$s +%2$d ακόμη θέλουν να συμμετάσχουν σε αυτό το δωμάτιο" + + "Προβολή όλων" + "Αποδοχή" + "Ο χρήστης %1$s θέλει να μπει σε αυτό το δωμάτιο" + "Προβολή" diff --git a/features/knockrequests/impl/src/main/res/values-et/translations.xml b/features/knockrequests/impl/src/main/res/values-et/translations.xml index bd647dcb09..79d9c56964 100644 --- a/features/knockrequests/impl/src/main/res/values-et/translations.xml +++ b/features/knockrequests/impl/src/main/res/values-et/translations.xml @@ -14,4 +14,12 @@ "Kui keegi soovib jututoaga liituda, siis need päringud on kuvatud siin." "Pole ühtegi liitumispalvet" "Liitumispalved" + + "%1$s + veel %2$d kasutaja soovivad selle jututoaga liituda" + "%1$s + veel %2$d kasutajat soovivad selle jututoaga liituda" + + "Vaata kõiki" + "Nõustu" + "%1$s soovib selle jututoaga liituda" + "Vaata" diff --git a/features/knockrequests/impl/src/main/res/values-fr/translations.xml b/features/knockrequests/impl/src/main/res/values-fr/translations.xml index 6632df1dd3..39bc75f5c1 100644 --- a/features/knockrequests/impl/src/main/res/values-fr/translations.xml +++ b/features/knockrequests/impl/src/main/res/values-fr/translations.xml @@ -14,4 +14,12 @@ "Lorsque quelqu’un demandera à rejoindre le salon, vous pourrez voir sa demande ici." "Personne ne demande à rejoindre le salon" "Demandes en attente" + + "%1$s et %2$d autre personne souhaitent rejoindre ce salon" + "%1$s et %2$d autres personnes souhaitent rejoindre ce salon" + + "Tout afficher" + "Accepter" + "%1$s souhaite rejoindre ce salon" + "Voir" diff --git a/features/knockrequests/impl/src/main/res/values-hu/translations.xml b/features/knockrequests/impl/src/main/res/values-hu/translations.xml index 2093d0103d..ba1aa620c6 100644 --- a/features/knockrequests/impl/src/main/res/values-hu/translations.xml +++ b/features/knockrequests/impl/src/main/res/values-hu/translations.xml @@ -14,4 +14,12 @@ "Ha valaki csatlakozni kíván a szobához, itt láthatja a kérését." "Nincs függőben lévő csatlakozási kérelem" "Csatlakozási kérelmek" + + "%1$s és még %2$d felhasználó szeretne csatlakozni ehhez a szobához" + "%1$s és még %2$d felhasználó szeretne csatlakozni ehhez a szobához" + + "Összes megtekintése" + "Elfogadás" + "%1$s szeretne csatlakozni ehhez a szobához" + "Megtekintés" diff --git a/features/knockrequests/impl/src/main/res/values-it/translations.xml b/features/knockrequests/impl/src/main/res/values-it/translations.xml index a3e05bb8ef..ebdba8074a 100644 --- a/features/knockrequests/impl/src/main/res/values-it/translations.xml +++ b/features/knockrequests/impl/src/main/res/values-it/translations.xml @@ -14,4 +14,12 @@ "Quando qualcuno ti chiederà di entrare nella stanza, potrai vedere la sua richiesta qui." "Nessuna richiesta di accesso in sospeso" "Richieste di accesso" + + "%1$s +%2$d vogliono entrare in questa stanza" + "%1$s +%2$d vogliono entrare in questa stanza" + + "Visualizza tutte" + "Accetta" + "%1$s vuole entrare in questa stanza" + "Visualizza" diff --git a/features/knockrequests/impl/src/main/res/values-ru/translations.xml b/features/knockrequests/impl/src/main/res/values-ru/translations.xml index 2542195139..1023af2d28 100644 --- a/features/knockrequests/impl/src/main/res/values-ru/translations.xml +++ b/features/knockrequests/impl/src/main/res/values-ru/translations.xml @@ -14,4 +14,13 @@ "Вы сможете увидеть запрос, когда кто-то попросит присоединиться к комнате." "Нет ожидающих запросов на присоединение" "Запросы на присоединение" + + "%1$s +%2$d хочет присоединиться к этой комнате" + "%1$s +%2$d хотят присоединиться к этой комнате" + "%1$s +%2$d хотят присоединиться к этой комнате" + + "Показать все" + "Принять" + "%1$s хочет присоединиться к этой комнате" + "Просмотр" diff --git a/features/knockrequests/impl/src/main/res/values-sk/translations.xml b/features/knockrequests/impl/src/main/res/values-sk/translations.xml index 4dff32780a..1504ef8631 100644 --- a/features/knockrequests/impl/src/main/res/values-sk/translations.xml +++ b/features/knockrequests/impl/src/main/res/values-sk/translations.xml @@ -5,4 +5,13 @@ "Keď niekto požiada, aby sa pripojil k miestnosti, jeho žiadosť si môžete pozrieť tu." "Žiadna čakajúca žiadosť o pripojenie" "Žiadosti o pripojenie" + + "%1$s +%2$d ďalší chcú vstúpiť do tejto miestnosti" + "%1$s +%2$d ďalší chcú vstúpiť do tejto miestnosti" + "%1$s +%2$d ďalších chce vstúpiť do tejto miestnosti" + + "Zobraziť všetko" + "Prijať" + "%1$s chce vstúpiť do tejto miestnosti" + "Zobraziť" diff --git a/libraries/mediaviewer/impl/src/main/res/values-cs/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000000..79f3b646ee --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,10 @@ + + + "Obrázky a videa nahraná do této místnosti budou zobrazeny zde." + "Zatím nebyla nahrána žádná média" + "Načítání souborů…" + "Načítání médií…" + "Soubory" + "Média" + "Média a soubory" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-de/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000000..07438166f6 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,14 @@ + + + "In diesen Chatroom hochgeladene Bilder und Videos werden hier angezeigt." + "Noch keine Medien hochgeladen" + "Dateien werden geladen…" + "Medien werden geladen…" + "Dateien" + "Medien" + "Medien und Dateien" + "Dateiformat" + "Dateiname" + "Hochgeladen von" + "Hochgeladen am" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-et/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000000..396138c100 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,16 @@ + + + "Järgnevaga eemaldame selle faili jututoast ka tema liikmed enam ei pääse failile ligi." + "Kas kustutame faili?" + "Antud jututuppa üleslaaditud pildid ja videod kuvatakse siin." + "Mitte keegi pole veel meediat üles laadinud" + "Laadime faile…" + "Laadime meediat…" + "Failid" + "Meedia" + "Meedia ja failid" + "Failivorming" + "Failinimi" + "Üleslaadija" + "Üleslaaditud" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-fr/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000000..bd961fb941 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,16 @@ + + + "Ce fichier sera supprimé du salon et les membres n’y auront plus accès." + "Supprimer le fichier ?" + "Les images et vidéos envoyées dans ce salon seront affichées ici." + "Aucun média n’a encore été envoyé dans ce salon" + "Chargement des fichiers…" + "Chargement des médias…" + "Fichiers" + "Média" + "Médias et fichiers" + "Format du fichier" + "Nom du fichier" + "Envoyé par" + "Envoyé le" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-hu/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000000..1fcc528dc5 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,16 @@ + + + "Ez a fájl el lesz távolítva a szobából, és a tagok nem férhetnek hozzá." + "Törli a fájlt?" + "Az ebbe a szobába feltöltött képek és videók itt jelennek meg." + "Még nincs feltöltött média" + "Fájlok betöltése…" + "Média betöltése…" + "Fájlok" + "Média" + "Média és fájlok" + "Fájlformátum" + "Fájlnév" + "Feltöltötte:" + "Feltöltve:" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-it/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..45d160f3d2 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,8 @@ + + + "Le immagini e i video caricati in questa stanza verranno mostrati qui." + "Nessun file multimediale ancora caricato" + "File" + "Contenuti multimediali" + "File e contenuti multimediali" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-ru/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000000..713b748617 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,16 @@ + + + "Этот файл будет удален из комнаты и участники не будут иметь к нему доступ." + "Удалить файл?" + "Здесь будут показаны изображения и видео, загруженные в данную комнату." + "Пока что нет загруженных медиафайлов" + "Загрузка файлов…" + "Загрузка медиа…" + "Файлы" + "Медиа" + "Медиа и файлы" + "Формат файла" + "Имя файла" + "Загружено" + "Загружено на" + diff --git a/libraries/mediaviewer/impl/src/main/res/values/localazy.xml b/libraries/mediaviewer/impl/src/main/res/values/localazy.xml index 992b8edd88..b35a4819f1 100644 --- a/libraries/mediaviewer/impl/src/main/res/values/localazy.xml +++ b/libraries/mediaviewer/impl/src/main/res/values/localazy.xml @@ -11,6 +11,8 @@ "Media and files" "File format" "File name" + "This file will be removed from the room and members won’t have access to it." + "Delete file?" "Uploaded by" "Uploaded on" diff --git a/libraries/ui-strings/src/main/res/values-cs/translations.xml b/libraries/ui-strings/src/main/res/values-cs/translations.xml index f1b88a1c76..9479eb43c4 100644 --- a/libraries/ui-strings/src/main/res/values-cs/translations.xml +++ b/libraries/ui-strings/src/main/res/values-cs/translations.xml @@ -303,13 +303,6 @@ Důvod: %1$s." "Ahoj, ozvi se mi na %1$s: %2$s" "%1$s Android" "Zatřeste zařízením pro nahlášení chyby" - "Obrázky a videa nahraná do této místnosti budou zobrazeny zde." - "Zatím nebyla nahrána žádná média" - "Načítání souborů…" - "Načítání médií…" - "Soubory" - "Média" - "Média a soubory" "Výběr média se nezdařil, zkuste to prosím znovu." "Titulky nemusí být viditelné pro lidi, kteří používají starší aplikace." "Nahrání média se nezdařilo, zkuste to prosím znovu." @@ -334,19 +327,10 @@ Důvod: %1$s." "Vaše zpráva nebyla odeslána, protože jste neověřili jedno nebo více zařízení" "Nahrání média se nezdařilo, zkuste to prosím znovu." "Nepodařilo se načíst údaje o uživateli" - - "%1$s +%2$d další chce vstoupit do této místnosti" - "%1$s +%2$d další chtějí vstoupit do této místnosti" - "%1$s +%2$d dalších chce vstoupit do této místnosti" - - "Zobrazit vše" "%1$s z %2$s" "%1$s Připnuté zprávy" "Načítání zprávy…" "Zobrazit vše" - "Přijmout" - "%1$s chce vstoupit do této místnosti" - "Zobrazit" "Chat" "Žádost o vstup odeslána" "Sdílet polohu" diff --git a/libraries/ui-strings/src/main/res/values-de/translations.xml b/libraries/ui-strings/src/main/res/values-de/translations.xml index 4f2ca9bf97..0d7acd7e90 100644 --- a/libraries/ui-strings/src/main/res/values-de/translations.xml +++ b/libraries/ui-strings/src/main/res/values-de/translations.xml @@ -299,17 +299,6 @@ Grund: %1$s." "Hey, sprich mit mir auf %1$s: %2$s" "%1$s Android" "Schüttel heftig zum Melden von Fehlern" - "In diesen Chatroom hochgeladene Bilder und Videos werden hier angezeigt." - "Noch keine Medien hochgeladen" - "Dateien werden geladen…" - "Medien werden geladen…" - "Dateien" - "Medien" - "Medien und Dateien" - "Dateiformat" - "Dateiname" - "Hochgeladen von" - "Hochgeladen am" "Medienauswahl fehlgeschlagen, bitte versuche es erneut." "Bildunterschriften sind für Nutzer älterer Apps möglicherweise nicht sichtbar." "Fehler beim Verarbeiten des hochgeladenen Mediums. Bitte versuche es erneut." @@ -333,18 +322,10 @@ Grund: %1$s." "Ihre Nachricht wurde nicht geschickt, da Sie eines oder mehrere Ihrer Geräte nicht verifiziert haben." "Fehler beim Verarbeiten des hochgeladenen Mediums. Bitte versuche es erneut." "Benutzerdetails konnten nicht abgerufen werden" - - "%1$s+ %2$d andere wollen diesem Chatroom beitreten" - "%1$s+ %2$d andere wollen diesem Chatroom beitreten" - - "Alles ansehen" "%1$s von %2$s" "%1$s fixierte Nachrichten" "Nachricht wird geladen…" "Alle anzeigen" - "Akzeptieren" - "%1$s möchte diesem Chatroom beitreten" - "Ansicht" "Chat" "Beitrittsanfrage gesendet" "Standort teilen" diff --git a/libraries/ui-strings/src/main/res/values-el/translations.xml b/libraries/ui-strings/src/main/res/values-el/translations.xml index 89e96e3f3c..c05000f3a5 100644 --- a/libraries/ui-strings/src/main/res/values-el/translations.xml +++ b/libraries/ui-strings/src/main/res/values-el/translations.xml @@ -322,18 +322,10 @@ "Το μήνυμά σου δεν στάλθηκε επειδή δεν έχεις επαληθεύσει τουλάχιστον μία από τις συσκευές σου" "Αποτυχία μεταφόρτωσης μέσου, δοκίμασε ξανά." "Δεν ήταν δυνατή η ανάκτηση στοιχείων χρήστη" - - "Οι χρήστες %1$s +%2$d ακόμη θέλουν να συμμετάσχουν σε αυτό το δωμάτιο" - "Οι χρήστες %1$s +%2$d ακόμη θέλουν να συμμετάσχουν σε αυτό το δωμάτιο" - - "Προβολή όλων" "%1$s από %2$s" "%1$s Καρφιτσωμένα μηνύματα" "Φόρτωση μηνύματος…" "Προβολή Όλων" - "Αποδοχή" - "Ο χρήστης %1$s θέλει να μπει σε αυτό το δωμάτιο" - "Προβολή" "Συνομιλία" "Το αίτημα συμμετοχής στάλθηκε" "Κοινή χρήση τοποθεσίας" diff --git a/libraries/ui-strings/src/main/res/values-et/translations.xml b/libraries/ui-strings/src/main/res/values-et/translations.xml index b09e10b8b3..0d7f51baef 100644 --- a/libraries/ui-strings/src/main/res/values-et/translations.xml +++ b/libraries/ui-strings/src/main/res/values-et/translations.xml @@ -299,13 +299,6 @@ Põhjus: %1$s." "Hei, suhtle minuga %1$s võrgus: %2$s" "%1$s Android" "Veast teatamiseks raputa nutiseadet ägedalt" - "Antud jututuppa üleslaaditud pildid ja videod kuvatakse siin." - "Mitte keegi pole veel meediat üles laadinud" - "Laadime faile…" - "Laadime meediat…" - "Failid" - "Meedia" - "Meedia ja failid" "Meediafaili valimine ei õnnestunud. Palun proovi uuesti." "Selgitused ja alapealkirjad ei pruugi olla nähtavad vanemate rakenduste kasutajatele." "Meediafaili töötlemine enne üleslaadimist ei õnnestunud. Palun proovi uuesti." @@ -329,18 +322,10 @@ Põhjus: %1$s." "Kuna sul on üks või enam verifitseerimata seadet, siis sinu sõnum jäi saatmata" "Meediafaili töötlemine enne üleslaadimist ei õnnestunud. Palun proovi uuesti." "Kasutaja andmete laadimine ei õnnestunud" - - "%1$s + veel %2$d kasutaja soovivad selle jututoaga liituda" - "%1$s + veel %2$d kasutajat soovivad selle jututoaga liituda" - - "Vaata kõiki" "%1$s / %2$s" "%1$s esiletõstetud sõnumit" "Laadime sõnumit…" "Näita kõiki" - "Nõustu" - "%1$s soovib selle jututoaga liituda" - "Vaata" "Vestlus" "Liitumispäring on saadetud" "Jaga asukohta" diff --git a/libraries/ui-strings/src/main/res/values-fr/translations.xml b/libraries/ui-strings/src/main/res/values-fr/translations.xml index ac4bff851e..ecf513c269 100644 --- a/libraries/ui-strings/src/main/res/values-fr/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fr/translations.xml @@ -299,19 +299,6 @@ Raison : %1$s." "Salut, parle-moi sur %1$s : %2$s" "%1$s Android" "Rageshake pour signaler un problème" - "Ce fichier sera supprimé du salon et les membres n’y auront plus accès." - "Supprimer le fichier ?" - "Les images et vidéos envoyées dans ce salon seront affichées ici." - "Aucun média n’a encore été envoyé dans ce salon" - "Chargement des fichiers…" - "Chargement des médias…" - "Fichiers" - "Média" - "Médias et fichiers" - "Format du fichier" - "Nom du fichier" - "Envoyé par" - "Envoyé le" "Échec de la sélection du média, veuillez réessayer." "Les légendes peuvent ne pas être visibles pour les utilisateurs d’anciennes applications." "Échec du traitement des médias à télécharger, veuillez réessayer." @@ -335,18 +322,10 @@ Raison : %1$s." "Votre message n’a pas été envoyé car vous n’avez pas vérifié tous vos appareils" "Échec du traitement des médias à télécharger, veuillez réessayer." "Impossible de récupérer les détails de l’utilisateur" - - "%1$s et %2$d autre personne souhaitent rejoindre ce salon" - "%1$s et %2$d autres personnes souhaitent rejoindre ce salon" - - "Tout afficher" "%1$s sur %2$s" "%1$s Messages épinglés" "Chargement du message…" "Voir tout" - "Accepter" - "%1$s souhaite rejoindre ce salon" - "Voir" "Discussion" "Demande d’adhésion envoyée" "Partage de position" diff --git a/libraries/ui-strings/src/main/res/values-hu/translations.xml b/libraries/ui-strings/src/main/res/values-hu/translations.xml index b30c6f6896..0f1bb7507f 100644 --- a/libraries/ui-strings/src/main/res/values-hu/translations.xml +++ b/libraries/ui-strings/src/main/res/values-hu/translations.xml @@ -299,19 +299,6 @@ Ok: %1$s." "Beszélgessünk itt: %1$s, %2$s" "%1$s Android" "Az eszköz rázása a hibajelentéshez" - "Ez a fájl el lesz távolítva a szobából, és a tagok nem férhetnek hozzá." - "Törli a fájlt?" - "Az ebbe a szobába feltöltött képek és videók itt jelennek meg." - "Még nincs feltöltött média" - "Fájlok betöltése…" - "Média betöltése…" - "Fájlok" - "Média" - "Média és fájlok" - "Fájlformátum" - "Fájlnév" - "Feltöltötte:" - "Feltöltve:" "Nem sikerült kiválasztani a médiát, próbálja újra." "Előfordulhat, hogy a feliratok nem láthatók a régebbi alkalmazásokat használók számára." "Nem sikerült feldolgozni a feltöltendő médiát, próbálja újra." @@ -335,18 +322,10 @@ Ok: %1$s." "Az üzenet nem lett elküldve, mert egy vagy több eszközét nem ellenőrizte" "Nem sikerült feldolgozni a feltöltendő médiát, próbálja újra." "Nem sikerült letölteni a felhasználói adatokat" - - "%1$s és még %2$d felhasználó szeretne csatlakozni ehhez a szobához" - "%1$s és még %2$d felhasználó szeretne csatlakozni ehhez a szobához" - - "Összes megtekintése" "%1$s / %2$s" "%1$s kitűzött üzenet" "Üzenet betöltése…" "Összes megtekintése" - "Elfogadás" - "%1$s szeretne csatlakozni ehhez a szobához" - "Megtekintés" "Csevegés" "Csatlakozási kérés elküldve" "Hely megosztása" diff --git a/libraries/ui-strings/src/main/res/values-it/translations.xml b/libraries/ui-strings/src/main/res/values-it/translations.xml index 69bfd8cc95..68547a7f7e 100644 --- a/libraries/ui-strings/src/main/res/values-it/translations.xml +++ b/libraries/ui-strings/src/main/res/values-it/translations.xml @@ -299,11 +299,6 @@ Motivo:. %1$s" "Ehi, parliamo su %1$s: %2$s" "%1$s Android" "Scuoti per segnalare un problema" - "Le immagini e i video caricati in questa stanza verranno mostrati qui." - "Nessun file multimediale ancora caricato" - "File" - "Contenuti multimediali" - "File e contenuti multimediali" "Selezione del file multimediale fallita, riprova." "Le didascalie potrebbero non essere visibili agli utenti di app meno recenti." "Elaborazione del file multimediale da caricare fallita, riprova." @@ -327,18 +322,10 @@ Motivo:. %1$s" "Il tuo messaggio non è stato inviato perché non hai verificato uno o più dispositivi." "Elaborazione del file multimediale da caricare fallita, riprova." "Impossibile recuperare i dettagli dell\'utente" - - "%1$s +%2$d vogliono entrare in questa stanza" - "%1$s +%2$d vogliono entrare in questa stanza" - - "Visualizza tutte" "%1$s di %2$s" "%1$s Messaggi fissati" "Caricamento messaggio…" "Mostra tutti" - "Accetta" - "%1$s vuole entrare in questa stanza" - "Visualizza" "Conversazione" "Richiesta di accesso inviata" "Condividi posizione" diff --git a/libraries/ui-strings/src/main/res/values-ru/translations.xml b/libraries/ui-strings/src/main/res/values-ru/translations.xml index 7bb6675bd5..b2a05f452c 100644 --- a/libraries/ui-strings/src/main/res/values-ru/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ru/translations.xml @@ -303,13 +303,6 @@ "Привет, поговори со мной по %1$s: %2$s" "%1$s Android" "Встряхните устройство, чтобы сообщить об ошибке" - "Здесь будут показаны изображения и видео, загруженные в данную комнату." - "Пока что нет загруженных медиафайлов" - "Загрузка файлов…" - "Загрузка медиа…" - "Файлы" - "Медиа" - "Медиа и файлы" "Не удалось выбрать носитель, попробуйте еще раз." "Подпись может быть не видна пользователям старых приложений." "Не удалось обработать медиафайл для загрузки, попробуйте еще раз." @@ -334,19 +327,10 @@ "Ваше сообщение не было отправлено, поскольку вы не подтвердили одно или несколько своих устройств." "Не удалось обработать медиафайл для загрузки, попробуйте еще раз." "Не удалось получить данные о пользователе" - - "%1$s +%2$d хочет присоединиться к этой комнате" - "%1$s +%2$d хотят присоединиться к этой комнате" - "%1$s +%2$d хотят присоединиться к этой комнате" - - "Показать все" "%1$s из %2$s" "%1$s Закрепленные сообщения" "Загрузка сообщения…" "Посмотреть все" - "Принять" - "%1$s хочет присоединиться к этой комнате" - "Просмотр" "Чат" "Запрос на присоединение отправлен" "Поделиться местоположением" diff --git a/libraries/ui-strings/src/main/res/values-sk/translations.xml b/libraries/ui-strings/src/main/res/values-sk/translations.xml index c43567ea44..1c26d0eaae 100644 --- a/libraries/ui-strings/src/main/res/values-sk/translations.xml +++ b/libraries/ui-strings/src/main/res/values-sk/translations.xml @@ -323,19 +323,10 @@ Dôvod: %1$s." "Vaša správa nebola odoslaná, pretože ste neoverili jedno alebo viac svojich zariadení" "Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova." "Nepodarilo sa získať údaje o používateľovi" - - "%1$s +%2$d ďalší chcú vstúpiť do tejto miestnosti" - "%1$s +%2$d ďalší chcú vstúpiť do tejto miestnosti" - "%1$s +%2$d ďalších chce vstúpiť do tejto miestnosti" - - "Zobraziť všetko" "%1$s z %2$s" "%1$s Pripnutých správ" "Načítava sa správa…" "Zobraziť všetko" - "Prijať" - "%1$s chce vstúpiť do tejto miestnosti" - "Zobraziť" "Konverzácia" "Žiadosť o vstup odoslaná" "Zdieľať polohu" diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 27be174f00..b571a23230 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -148,6 +148,7 @@ "Device ID" "Direct chat" "Do not show this again" + "Downloading" "(edited)" "Editing" "Editing caption" @@ -299,19 +300,6 @@ Reason: %1$s." "Hey, talk to me on %1$s: %2$s" "%1$s Android" "Rageshake to report bug" - "This file will be removed from the room and members won’t have access to it." - "Delete file?" - "Images and videos uploaded to this room will be shown here." - "No media uploaded yet" - "Loading files…" - "Loading media…" - "Files" - "Media" - "Media and files" - "File format" - "File name" - "Uploaded by" - "Uploaded on" "Failed selecting media, please try again." "Captions might not be visible to people using older apps." "Failed processing media to upload, please try again." @@ -355,6 +343,7 @@ Reason: %1$s." "en" "en" "Historical messages are not available on this device" + "You don\'t have access to this message" "Unable to decrypt message" "This message was blocked either because you did not verify your device or because the sender needs to verify your identity." From d81859705d5ff5b6b0eb6a7c181383de5611d7ce Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 9 Dec 2024 17:33:18 +0100 Subject: [PATCH 03/31] Fix preview. --- .../impl/gallery/MediaGalleryStateProvider.kt | 41 +++++++++++-------- .../impl/gallery/ui/MediaItemImageProvider.kt | 3 +- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt index d0a92f1b5d..d357ae7a0c 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt @@ -9,6 +9,7 @@ package io.element.android.libraries.mediaviewer.impl.gallery import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.architecture.AsyncData +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.gallery.ui.aDate import io.element.android.libraries.mediaviewer.impl.gallery.ui.aFile @@ -27,16 +28,19 @@ open class MediaGalleryStateProvider : PreviewParameterProvider Date: Mon, 9 Dec 2024 16:45:31 +0000 Subject: [PATCH 04/31] Update screenshots --- .../images/features.roomdetails.impl_RoomDetailsDark_0_en.png | 4 ++-- .../features.roomdetails.impl_RoomDetailsDark_10_en.png | 4 ++-- .../features.roomdetails.impl_RoomDetailsDark_11_en.png | 4 ++-- .../features.roomdetails.impl_RoomDetailsDark_12_en.png | 4 ++-- .../features.roomdetails.impl_RoomDetailsDark_13_en.png | 4 ++-- .../images/features.roomdetails.impl_RoomDetailsDark_1_en.png | 4 ++-- .../images/features.roomdetails.impl_RoomDetailsDark_2_en.png | 4 ++-- .../images/features.roomdetails.impl_RoomDetailsDark_3_en.png | 4 ++-- .../images/features.roomdetails.impl_RoomDetailsDark_4_en.png | 4 ++-- .../images/features.roomdetails.impl_RoomDetailsDark_5_en.png | 4 ++-- .../images/features.roomdetails.impl_RoomDetailsDark_6_en.png | 4 ++-- .../images/features.roomdetails.impl_RoomDetailsDark_7_en.png | 4 ++-- .../images/features.roomdetails.impl_RoomDetailsDark_8_en.png | 4 ++-- .../images/features.roomdetails.impl_RoomDetailsDark_9_en.png | 4 ++-- .../images/features.roomdetails.impl_RoomDetails_0_en.png | 4 ++-- .../images/features.roomdetails.impl_RoomDetails_10_en.png | 4 ++-- .../images/features.roomdetails.impl_RoomDetails_11_en.png | 4 ++-- .../images/features.roomdetails.impl_RoomDetails_12_en.png | 4 ++-- .../images/features.roomdetails.impl_RoomDetails_13_en.png | 4 ++-- .../images/features.roomdetails.impl_RoomDetails_1_en.png | 4 ++-- .../images/features.roomdetails.impl_RoomDetails_2_en.png | 4 ++-- .../images/features.roomdetails.impl_RoomDetails_3_en.png | 4 ++-- .../images/features.roomdetails.impl_RoomDetails_4_en.png | 4 ++-- .../images/features.roomdetails.impl_RoomDetails_5_en.png | 4 ++-- .../images/features.roomdetails.impl_RoomDetails_6_en.png | 4 ++-- .../images/features.roomdetails.impl_RoomDetails_7_en.png | 4 ++-- .../images/features.roomdetails.impl_RoomDetails_8_en.png | 4 ++-- .../images/features.roomdetails.impl_RoomDetails_9_en.png | 4 ++-- ...es.designsystem.components.avatar_Avatar_Avatars_84_en.png | 3 +++ ...es.designsystem.components.avatar_Avatar_Avatars_85_en.png | 3 +++ ...es.designsystem.components.avatar_Avatar_Avatars_86_en.png | 3 +++ ...pl.details_MediaDeleteConfirmationBottomSheet_Day_0_en.png | 3 +++ ....details_MediaDeleteConfirmationBottomSheet_Night_0_en.png | 3 +++ ...iaviewer.impl.details_MediaDetailsBottomSheet_Day_0_en.png | 3 +++ ...viewer.impl.details_MediaDetailsBottomSheet_Night_0_en.png | 3 +++ ...ries.mediaviewer.impl.gallery.ui_DateItemView_Day_0_en.png | 3 +++ ...ries.mediaviewer.impl.gallery.ui_DateItemView_Day_1_en.png | 3 +++ ...es.mediaviewer.impl.gallery.ui_DateItemView_Night_0_en.png | 3 +++ ...es.mediaviewer.impl.gallery.ui_DateItemView_Night_1_en.png | 3 +++ ...ries.mediaviewer.impl.gallery.ui_FileItemView_Day_0_en.png | 3 +++ ...ries.mediaviewer.impl.gallery.ui_FileItemView_Day_1_en.png | 3 +++ ...ries.mediaviewer.impl.gallery.ui_FileItemView_Day_2_en.png | 3 +++ ...es.mediaviewer.impl.gallery.ui_FileItemView_Night_0_en.png | 3 +++ ...es.mediaviewer.impl.gallery.ui_FileItemView_Night_1_en.png | 3 +++ ...es.mediaviewer.impl.gallery.ui_FileItemView_Night_2_en.png | 3 +++ ...ies.mediaviewer.impl.gallery.ui_ImageItemView_Day_0_en.png | 3 +++ ...s.mediaviewer.impl.gallery.ui_ImageItemView_Night_0_en.png | 3 +++ ...ies.mediaviewer.impl.gallery.ui_VideoItemView_Day_0_en.png | 3 +++ ...ies.mediaviewer.impl.gallery.ui_VideoItemView_Day_1_en.png | 3 +++ ...s.mediaviewer.impl.gallery.ui_VideoItemView_Night_0_en.png | 3 +++ ...s.mediaviewer.impl.gallery.ui_VideoItemView_Night_1_en.png | 3 +++ ...ies.mediaviewer.impl.gallery_MediaGalleryView_Day_0_en.png | 3 +++ ...ies.mediaviewer.impl.gallery_MediaGalleryView_Day_1_en.png | 3 +++ ...ies.mediaviewer.impl.gallery_MediaGalleryView_Day_2_en.png | 3 +++ ...ies.mediaviewer.impl.gallery_MediaGalleryView_Day_3_en.png | 3 +++ ...ies.mediaviewer.impl.gallery_MediaGalleryView_Day_4_en.png | 3 +++ ...ies.mediaviewer.impl.gallery_MediaGalleryView_Day_5_en.png | 3 +++ ...ies.mediaviewer.impl.gallery_MediaGalleryView_Day_6_en.png | 3 +++ ...ies.mediaviewer.impl.gallery_MediaGalleryView_Day_7_en.png | 3 +++ ...ies.mediaviewer.impl.gallery_MediaGalleryView_Day_8_en.png | 3 +++ ...s.mediaviewer.impl.gallery_MediaGalleryView_Night_0_en.png | 3 +++ ...s.mediaviewer.impl.gallery_MediaGalleryView_Night_1_en.png | 3 +++ ...s.mediaviewer.impl.gallery_MediaGalleryView_Night_2_en.png | 3 +++ ...s.mediaviewer.impl.gallery_MediaGalleryView_Night_3_en.png | 3 +++ ...s.mediaviewer.impl.gallery_MediaGalleryView_Night_4_en.png | 3 +++ ...s.mediaviewer.impl.gallery_MediaGalleryView_Night_5_en.png | 3 +++ ...s.mediaviewer.impl.gallery_MediaGalleryView_Night_6_en.png | 3 +++ ...s.mediaviewer.impl.gallery_MediaGalleryView_Night_7_en.png | 3 +++ ...s.mediaviewer.impl.gallery_MediaGalleryView_Night_8_en.png | 3 +++ ...libraries.mediaviewer.impl.viewer_MediaViewerView_0_en.png | 4 ++-- ...libraries.mediaviewer.impl.viewer_MediaViewerView_1_en.png | 4 ++-- ...libraries.mediaviewer.impl.viewer_MediaViewerView_2_en.png | 4 ++-- ...libraries.mediaviewer.impl.viewer_MediaViewerView_3_en.png | 4 ++-- ...libraries.mediaviewer.impl.viewer_MediaViewerView_4_en.png | 4 ++-- ...libraries.mediaviewer.impl.viewer_MediaViewerView_5_en.png | 4 ++-- ...libraries.mediaviewer.impl.viewer_MediaViewerView_6_en.png | 4 ++-- ...libraries.mediaviewer.impl.viewer_MediaViewerView_7_en.png | 4 ++-- ...libraries.mediaviewer.impl.viewer_MediaViewerView_8_en.png | 4 ++-- ...libraries.mediaviewer.impl.viewer_MediaViewerView_9_en.png | 4 ++-- 79 files changed, 199 insertions(+), 76 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_84_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_85_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_86_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDeleteConfirmationBottomSheet_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDeleteConfirmationBottomSheet_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDetailsBottomSheet_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDetailsBottomSheet_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_DateItemView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_DateItemView_Day_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_DateItemView_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_DateItemView_Night_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Day_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Day_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Night_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Night_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_ImageItemView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_ImageItemView_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_VideoItemView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_VideoItemView_Day_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_VideoItemView_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_VideoItemView_Night_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_5_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_6_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_7_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_8_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_5_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_6_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_7_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_8_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_0_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_0_en.png index 76446e6180..12b3132118 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:94f040a3d18493f80b5f90eb48e68c664de5ddee0ae4575905ce35709d31abe9 -size 40969 +oid sha256:c7828106cd3724769c5bbeaad50c3417264abcb6af40ffd90aee283e8b29e579 +size 41831 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_10_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_10_en.png index 52912aa0fd..58d7c45b3d 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:96cf72bdceae29a86593ed3bd02d5edbe1f5422e5be0798f536b49805088b0b8 -size 45109 +oid sha256:f9c24abc59ed8ef26f647b5d3855b768af6043bdbb035f0d2756a7b783f64561 +size 39848 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_11_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_11_en.png index 0ae9c7dc0e..e2e7745abc 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_11_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0c719ba2c0782ecf8ebf37c07dbc79d37b1d993e4987388ceafbefb31b03d100 -size 44064 +oid sha256:b87d165479dd2a0d6497fdac37bb43de760fd0eade06ad23b53baff667b8af03 +size 38783 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_12_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_12_en.png index fba01a25c6..09080780f9 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_12_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_12_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a675afee3fcef0f8468ec93e33e1e86398bda517f4f54615aaf527d549387431 -size 47217 +oid sha256:2e7726872c78a2bcddfefa689699d7bf69a09b55c023bbc7008575fbec5b7779 +size 41986 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_13_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_13_en.png index b3273a0efd..62a3a99fba 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_13_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_13_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ced35352da8f7b6c9d4a5647cf1ff29f194d4f68ca9eec9c268ab889271e4776 -size 45507 +oid sha256:6235b9e9b6ffb9e4d813cfa49c9819a3cbc112d6dc5d25d7901a257e4353e609 +size 40241 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_1_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_1_en.png index 4a0208786f..4f70fd407b 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3444cc70e1f1b212d89ba404199a439a498281aa9faff9a9bb2469b727498224 -size 37486 +oid sha256:75bcd07324f5acb45552d8d5bbe369cece798d109f3c096859c6a88fccc8e2e1 +size 39797 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_2_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_2_en.png index 16b2995961..5591d4b251 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:afcf1a235cd16b501ec02f7da90cf4800df41ab07383b7e6ed502f4e9249855e -size 38354 +oid sha256:f69da1fef91846329a0835dfc2be82b1f49892193dadf139f6ac262355aff86e +size 39910 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_3_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_3_en.png index cedbb0f72d..406387611a 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5338fba98c85142b4300467a564e8920627ba83ec91dffb7e2370d07447b8d78 -size 38479 +oid sha256:2ddb13a08e77486addc983662966128492074b5a2ddc4afb193ba459c8366952 +size 40544 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_4_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_4_en.png index f7c16b996f..ac33cc9543 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9c7887b5f1cc07ef30ef347c149af51edbc1c4539615c04fef57737839677423 -size 44293 +oid sha256:003386cc6af1fb6c3d52724e234627021a131f6eaf4c5261f58bfbba9d87bb54 +size 40981 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_5_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_5_en.png index 1774b7ba41..15c85d775e 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:11c9af054eb293134755003d8864305d9f2ef9a7597df795e4354e4f7c420166 -size 42209 +oid sha256:9f50132f14d4f26077cbb44e616b4b76070fd6df20e14af740e2743d1fc874b8 +size 40020 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_6_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_6_en.png index 8b0e9ee674..55ca265ccf 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d3bd2e90f06f259b158b6438ff4cd045733362ad215b933ddbb855043a5b8fa2 -size 38463 +oid sha256:76f09daad900e1c3eac91d07ec81c6a714f80ae9a734e3a3921dc5b259bab278 +size 40502 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_7_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_7_en.png index 52789eb061..4bc9febcfd 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ae95b22ef977a95f9de6dba9e8ed2b33ebca808db4437512be39f020fd8831da -size 46411 +oid sha256:0b2d15ec833e6e20fe302d377c11f686275d5d115dbba070d623c46f66f95f95 +size 41111 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_8_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_8_en.png index aa849d237d..4aaa0ce01a 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2724c07c5ce097c2c470ecb4168fe2d101fb56a2b37a88065aa9210b14b871be -size 45403 +oid sha256:0f8a39cfe4c761462b84425e1008a5c36c8f93a6d00e7e6f2ece108575bf6b89 +size 40124 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_9_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_9_en.png index c4aca87a18..3a7de20375 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_9_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:df340c45b1d0a07f53adcc2872aef1a691c4fe4d0280e961524281a3dc1e427b -size 45412 +oid sha256:959b422fefa73279a7a3ef2193c7cd5c597fbedbaddea7de245cc86b820c2514 +size 40158 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_0_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_0_en.png index a6694758f1..8e8c5d5fcd 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bee1a47e22df24ba29b46dac1c8c2da8c38b3d438f8ef5b72dc3c39b0900338a -size 41908 +oid sha256:da5e1b0b8dc2663baf0e491878868f43c078d6be0d81d4776dacbd28f34d794c +size 42866 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_10_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_10_en.png index c09b745e24..11c6879648 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:655995891afd39283b5271280d594d6b2ca0e3ff004e81ebbc4e351be0cd185e -size 46007 +oid sha256:0837ae930f448d0cf0f0614c437ead15398cafa5a96aa50e0967cf297d3ff355 +size 40662 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_11_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_11_en.png index c3ddd6dd92..2485aea5d3 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_11_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ff42edd6e8f1165bfe8ad24ad2e1a37a34138b30193283ceb070e09273c37247 -size 44976 +oid sha256:72b557a35f41804729912f653ff64a70c9173aeff63471367bf4f5e88bd8e7b8 +size 39664 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_12_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_12_en.png index ea86ba1b2e..c6b95729a7 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_12_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_12_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a21c4945fa617d0bdd5549c98a0b18067f302cb71e7e012728a61120a6ba7269 -size 47772 +oid sha256:684bae4170ee939c3317880889fa28e4c467ddf704a13280ca9a1e33d9ac7776 +size 42526 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_13_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_13_en.png index b4d73c57b7..72c9a2497c 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_13_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_13_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dc76558ad62b1d9ec77afd066b2c64edc7241b93aaa431d8545041f7024315b1 -size 46443 +oid sha256:2c96854d4054cec6a4e3f2f642def4abc500f9a22dfe1aadd24cf45eb326a815 +size 41109 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_1_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_1_en.png index fec1bbe806..34156265b9 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ab627c807db2e5bcf339f01fd0f11f5e79529b32165d96f2a192faa863c38dd4 -size 38380 +oid sha256:f47468b9b9dc5c7c91b2d0b1446fd3f6e45ea9ee5b760029421a28e52410db77 +size 40935 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_2_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_2_en.png index 7f4fb4df94..17e997b919 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:af7f4944f2e1bd57e1a02716ff02a67beeb8a05847a0d06387fa0a8ca5ec0481 -size 39221 +oid sha256:0ff7f7f25df81af089c0c22bb7937f01ea1d05e7acc09de1fd8c355f03c329c7 +size 41035 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_3_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_3_en.png index fe253a9a6e..cdf150fbc0 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:647a6e7c0fdbb3d89aa411c14a2f41b127dd597fa157f4ef37a909132368c47e -size 39114 +oid sha256:b6259c338f58959fc732676553418f8b6dfd5e50cd3b55ceac75b5d95a9feda5 +size 41323 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_4_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_4_en.png index d0bb2ef17c..5c7a431cbd 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c0ea6b1bf786b06fbe4ad211f9dbda7094f30f5089f7bb186d4024f064612785 -size 45210 +oid sha256:2fde798527f42790e6fe230dc896481223db0481605f551e5f7e840a3a78566b +size 42028 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_5_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_5_en.png index 045e6fbe0a..686029b8da 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7cf9b0b10c112a964bbc85e6c14b634d96460884992c53e8ae9408ec5a94455a -size 43073 +oid sha256:1d133a0d749da6a68605594ce69494fb8bb8094023a80e617872a40789bd4213 +size 40891 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_6_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_6_en.png index 8a5a3a51ae..48df048115 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1aa79cc35f4f4e9f6221b1f6cf119906bef17cc62268fae01ff1ec713931b7b9 -size 39619 +oid sha256:fbecca870bf0bedc0db8195b222b75408e8a2846424cf9de9fcf86e7342734d9 +size 41750 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_7_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_7_en.png index 8656744f17..12816f97f3 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:526a16419357ee26e460511190d95c4f9adf8686d8a688704634025298b5153e -size 47451 +oid sha256:3c1002160e799e3fb8c122f030f76dc29b0a766e533a9fe2151c9244777293f8 +size 42181 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_8_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_8_en.png index 34883fce29..3e61505a0e 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fe2e1f53003df2f9fd33e90861221a4adec4e4104ddf1162502b70a895b798eb -size 46410 +oid sha256:139702b064abb7b4b2d862e6f05730e2d3cc631cde28bde6c2a0770c5e08dbd1 +size 41072 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_9_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_9_en.png index 5fa82cc6ce..d4bea56772 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_9_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:44e97087d7fefeb63beb81ef0e52ea6616820bf325217f75aa0a11806f6c4313 -size 46366 +oid sha256:a9c44b7df1330d879fb78c3cde3e4664a470fec1508183f274cb5c572d457993 +size 41033 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_84_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_84_en.png new file mode 100644 index 0000000000..d78e8bad2a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_84_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:938fb1c1ade57ac6421387d9ea5142448842a32d7ce446d4743d1aa15b2944d9 +size 15284 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_85_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_85_en.png new file mode 100644 index 0000000000..deb69c6aa1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_85_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a7619c6bc9d0cb6ea7660992ed16ce24eafe10052f9a8a6e7708f7bc5c079fdc +size 14541 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_86_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_86_en.png new file mode 100644 index 0000000000..bd51d8c202 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_86_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4bef7c3d043454a4faf858456c7fcc98d1a17a0846a891af3332e76c4e10b553 +size 17102 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDeleteConfirmationBottomSheet_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDeleteConfirmationBottomSheet_Day_0_en.png new file mode 100644 index 0000000000..a9e55101ce --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDeleteConfirmationBottomSheet_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c3331fd0021785394b1f2b274433da2e1b2c01c001af57840fb69436c84a2345 +size 31436 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDeleteConfirmationBottomSheet_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDeleteConfirmationBottomSheet_Night_0_en.png new file mode 100644 index 0000000000..11f9963897 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDeleteConfirmationBottomSheet_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8eb047b7c69436b6c131ffdc1bea2883240fee64d57163d9113cf6fa184e8081 +size 30042 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDetailsBottomSheet_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDetailsBottomSheet_Day_0_en.png new file mode 100644 index 0000000000..55e944524e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDetailsBottomSheet_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4f3557280a4010e7ffdb6f22c11561573780c0b24c27e264073d3a3899169014 +size 29659 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDetailsBottomSheet_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDetailsBottomSheet_Night_0_en.png new file mode 100644 index 0000000000..33c24c3341 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDetailsBottomSheet_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:63efb9744640b71cfde672821c7c1ea8b33f9c3c2e2ad4d5f41149b9749b31c2 +size 28016 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_DateItemView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_DateItemView_Day_0_en.png new file mode 100644 index 0000000000..fd7b7e6443 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_DateItemView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:467d501edd69dc1cc2ee57b2558ed6215f0464495d09a421692c2e30d4dc0fb3 +size 6473 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_DateItemView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_DateItemView_Day_1_en.png new file mode 100644 index 0000000000..f835ed50a3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_DateItemView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b4a741963a984c6f491290fda0f399a73a92099cbef9a6c79676a1f89bbc53b1 +size 9050 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_DateItemView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_DateItemView_Night_0_en.png new file mode 100644 index 0000000000..a0e5cbaec3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_DateItemView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:45592dde62aa3f5272bb63df450b5eb76f634caadbabc0ac416c27882edb2ecc +size 6340 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_DateItemView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_DateItemView_Night_1_en.png new file mode 100644 index 0000000000..8430ef926c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_DateItemView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c5298e5212daba4deda2bb931f9de1af660af559dc77f092622ded925125233e +size 8898 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Day_0_en.png new file mode 100644 index 0000000000..2d5f0a7613 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:421bd0bd141a6f6b38ce938c68cb21672b1d3c9cbb83e8cd44fe47bbc2488c3f +size 11168 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Day_1_en.png new file mode 100644 index 0000000000..fc5c101c2e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c94c521f50f3b8d2fc5e03365e54e8582ee48d31a47146abfb34cc41972fd38b +size 15539 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Day_2_en.png new file mode 100644 index 0000000000..e28f8c7a7c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3a8d98c3b6bd629ca6318fb7ac8d989960bf2bb952a9719a305568864d3bbb77 +size 38554 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Night_0_en.png new file mode 100644 index 0000000000..e73f79b22d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:00f111de4ffa63cfb0cc047ea017a2e740f52a9d0786fadf379937c12ae0e199 +size 10715 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Night_1_en.png new file mode 100644 index 0000000000..5663da541d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2637ec2e1303341c473cb9380c18fb7c7c1558c1dbd39dcff8d71744e4c3f5c9 +size 14901 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Night_2_en.png new file mode 100644 index 0000000000..84aae36b0a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:57b058e64c8361a70492056a09d13e87c4e1b61fbfe785198282d035d27326ac +size 37041 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_ImageItemView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_ImageItemView_Day_0_en.png new file mode 100644 index 0000000000..a027c89303 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_ImageItemView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:15d3fa6a95cda6bca06ad79d3f4862db05e38111cdcac47c1cdd3aa204bc1f97 +size 4210 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_ImageItemView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_ImageItemView_Night_0_en.png new file mode 100644 index 0000000000..503f2bb229 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_ImageItemView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:abaae9e0c6bf9d7dec701e9a51592e89408668e0a2b8325731efdfdc73978acd +size 3667 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_VideoItemView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_VideoItemView_Day_0_en.png new file mode 100644 index 0000000000..f5caf4274b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_VideoItemView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:70f93330adb987d6f98d654670ab0898957d765ad3d92a47c9a1c781b24f9059 +size 5317 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_VideoItemView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_VideoItemView_Day_1_en.png new file mode 100644 index 0000000000..1821a79941 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_VideoItemView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3ffac5911f928411fa0ac94e9ac59f6b8bb8bce1016e06f348d946f9d10053e5 +size 4539 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_VideoItemView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_VideoItemView_Night_0_en.png new file mode 100644 index 0000000000..43d55840d9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_VideoItemView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e0ee87589ec0e4f7cf67775dfa69cd289aeb27f22087bd91d54102923a28557d +size 4775 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_VideoItemView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_VideoItemView_Night_1_en.png new file mode 100644 index 0000000000..f743d18269 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_VideoItemView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6504e09eb09a9e28bc70594e669ec87abd290b4fe20e2ee9b3588c2116a049cd +size 3994 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_0_en.png new file mode 100644 index 0000000000..14e6352729 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:29d55f260237060e5ac280d6c87f5eefbdaa9dc6710572cab38fbd41dea77090 +size 15465 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_1_en.png new file mode 100644 index 0000000000..14e6352729 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:29d55f260237060e5ac280d6c87f5eefbdaa9dc6710572cab38fbd41dea77090 +size 15465 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_2_en.png new file mode 100644 index 0000000000..14e65ac8bd --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:08b47d8b631032d7cb33cdd150eab67e4dc9e6498813e0f5107007c30143d249 +size 26058 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_3_en.png new file mode 100644 index 0000000000..9a840d69b9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a6c04486280ce42c65486d4c532c20521f0d03419f1ab042dc61c0b5ff1060c2 +size 18242 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_4_en.png new file mode 100644 index 0000000000..5544b7f86f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:88c68143c2bc7b098664d881bb6c0f2c35ac10ec690c0c7eb7ae6d9366144e83 +size 15136 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_5_en.png new file mode 100644 index 0000000000..5544b7f86f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:88c68143c2bc7b098664d881bb6c0f2c35ac10ec690c0c7eb7ae6d9366144e83 +size 15136 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_6_en.png new file mode 100644 index 0000000000..18144ddccb --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1b7055ce0a8214e7c445c2d1b7d30ad5ffc63617c9f9f837661dfbd057198681 +size 26092 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_7_en.png new file mode 100644 index 0000000000..18144ddccb --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_7_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1b7055ce0a8214e7c445c2d1b7d30ad5ffc63617c9f9f837661dfbd057198681 +size 26092 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_8_en.png new file mode 100644 index 0000000000..5b1aa7f25a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:789e94ac051194e1a43d2b211301537a17596fd30b7a670a7d39107dcfe6471d +size 40514 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_0_en.png new file mode 100644 index 0000000000..a8e5914937 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e379008ac3a5346b727bd9355a4a59b75a1a2eca8855ad4ef5b6d7c239da129b +size 15076 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_1_en.png new file mode 100644 index 0000000000..a8e5914937 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e379008ac3a5346b727bd9355a4a59b75a1a2eca8855ad4ef5b6d7c239da129b +size 15076 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_2_en.png new file mode 100644 index 0000000000..47b5c76148 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a99c2ae96a72551e0da2e6e3cce08a76fffc2f99763c81d5c3fe65a9604bbdc7 +size 25564 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_3_en.png new file mode 100644 index 0000000000..7a9b4af687 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7b332b50ff448deea9bb3fc0db047b99743bb16c730a9ef3dbb203b7ca8982dc +size 17067 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_4_en.png new file mode 100644 index 0000000000..30b2d63dd7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d414cb2e4268815943ce6d78fe8d85f576e055bd8ff4f652b7e15a664e3d8cb3 +size 14597 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_5_en.png new file mode 100644 index 0000000000..30b2d63dd7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d414cb2e4268815943ce6d78fe8d85f576e055bd8ff4f652b7e15a664e3d8cb3 +size 14597 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_6_en.png new file mode 100644 index 0000000000..aca9e44958 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4f07b50b154c426638c38897fca296f5d52fb0054ad459eadf8fbe344c9b0526 +size 25475 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_7_en.png new file mode 100644 index 0000000000..aca9e44958 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_7_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4f07b50b154c426638c38897fca296f5d52fb0054ad459eadf8fbe344c9b0526 +size 25475 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_8_en.png new file mode 100644 index 0000000000..5e1abf250a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:44a96a7ad9425d869b06d3339b8356fbb9d213f1ce1a987b57ddc8117885daea +size 38480 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_0_en.png index 93393fba05..7214efd2c9 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f8672f053256e9ab2984aa06a22e4c27f2dd3f3373f3d87c12bc3269297713fd -size 389602 +oid sha256:043da4a779363f5162b1fbb6b1159ab3ae3f6a1635473146a5b73242525b5e53 +size 390373 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_1_en.png index 4439239760..1ad4e9e788 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_1_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:33f460febda376d35c6eceb643dca374e9dbc76d30f21ccab99085a628e90e79 -size 389629 +oid sha256:a2c00187eb25b297f7debb7424969e5535e48366d139b7f073b6a7628f155d60 +size 390395 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_2_en.png index 061278ca3f..9511942ab8 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_2_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1b5c823df53d7a6fb2df235c2016b0e71bbf26aa2f7ec1c175e3360424b69c37 -size 94822 +oid sha256:94520145bde5de6a0d7820dd44d7f0243d9fb06eca062b19b72cb2457abdfb7d +size 95438 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_3_en.png index 5d77b5705d..475bdd1991 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_3_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4b5506ab9651470ae3fe08d89a1ee2783d333a0fc1a657e45d50c156d664e84c -size 396617 +oid sha256:82710704b6daff1c1e12a4a3c782f68744c0d6b3ee30b3fa1e38739176c6eaef +size 397724 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_4_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_4_en.png index dcbe73cf41..685149c94d 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_4_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e4a44ab880d3d9660c6671505d0575f6a89e21c4e4a16d5838b6826fe770473d -size 22041 +oid sha256:1547883dfc7ae3d742d2da2f6e5c6c2d6182d6039b3ce6075dbbe1d24f4ba341 +size 23370 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_5_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_5_en.png index c8856654b4..57c533a361 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_5_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1c7122bd07a25e38d8056ca2f9d297e2073c418d359b4d286a5ba3441bce1e4b -size 5712 +oid sha256:66add7cb3b696075b7e931b06d1d8472cfddc2fc1902081864c3d88754f7404a +size 6702 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_6_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_6_en.png index fbda655334..2142adf1d5 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_6_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f67a1db4ce45d9b5d2cf8f9f3bf0e5e6de439d1d9631a407826f984afdaf90c7 -size 14756 +oid sha256:a86ec4a40a63f646e62b1c6a3e481f0b68be442923e39b6750836ae4e5ac3045 +size 15577 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_7_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_7_en.png index 4ff875eb5c..8f3f77a739 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_7_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3b9d8de8cddf75a662c72d519eef8ae6409e109d944826147f71ddc97f2d5a40 -size 14954 +oid sha256:0abf1fdae34ea898f817f9667a02adb551d5e3bb528f73b4c2c01826f4ca375a +size 15881 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_8_en.png index 1d9b20bcf3..54fda061bc 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_8_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7e202c4748e6c0afcfdcfb77c3e3f14a234d0622e579ea926311e669486ba0e8 -size 13576 +oid sha256:c786dc4f2ec41fc334cd67febc105670c999da57cec1ea263daff2358ff5c766 +size 14406 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_9_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_9_en.png index 9698527a7d..8e2a70f3ce 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_9_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:646a010fc57669e8bd7a97cab1f5bfdd94c78a05cd02c0150a6b41ef82e8f466 -size 13687 +oid sha256:3f74e1b75f3e5a03df1b6b0e4509a4bf5da033c4f68cf88705ed136b471ae38c +size 14691 From 73143d88c08f0cdd8eccba1834e73fde195fd52c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 9 Dec 2024 17:52:59 +0100 Subject: [PATCH 05/31] Fix preview --- .../impl/details/MediaDeleteConfirmationBottomSheet.kt | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheet.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheet.kt index 9be9d84b6f..58b4bfaceb 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheet.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheet.kt @@ -23,7 +23,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -107,15 +106,9 @@ private fun MediaRow( .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { - val bgColor = if (LocalInspectionMode.current) { - ElementTheme.colors.bgDecorative1 - } else { - Color.Transparent - } Box( modifier = Modifier - .size(40.dp) - .background(bgColor), + .size(40.dp), ) { if (state.thumbnailSource == null) { BigIcon( From c2ad4a929b272de531383177bb903f506e68243c Mon Sep 17 00:00:00 2001 From: ElementBot Date: Mon, 9 Dec 2024 17:04:41 +0000 Subject: [PATCH 06/31] Update screenshots --- ...pl.details_MediaDeleteConfirmationBottomSheet_Day_0_en.png | 4 ++-- ....details_MediaDeleteConfirmationBottomSheet_Night_0_en.png | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDeleteConfirmationBottomSheet_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDeleteConfirmationBottomSheet_Day_0_en.png index a9e55101ce..137a7d8afe 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDeleteConfirmationBottomSheet_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDeleteConfirmationBottomSheet_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c3331fd0021785394b1f2b274433da2e1b2c01c001af57840fb69436c84a2345 -size 31436 +oid sha256:8070f1089c8d151b74558046ca70d3c92525d80109dcc082ac05be5678b7b6e0 +size 31230 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDeleteConfirmationBottomSheet_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDeleteConfirmationBottomSheet_Night_0_en.png index 11f9963897..7a2ce52d92 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDeleteConfirmationBottomSheet_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDeleteConfirmationBottomSheet_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8eb047b7c69436b6c131ffdc1bea2883240fee64d57163d9113cf6fa184e8081 -size 30042 +oid sha256:2dc3ac99e6894376a01f3f9cdbe8efcfd43233ada646944634489620535d326e +size 29603 From ae0593d555d51fc8932144df491ab090cdcebeb2 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 10 Dec 2024 08:07:12 +0100 Subject: [PATCH 07/31] Rename preview. --- .../libraries/mediaviewer/impl/gallery/ui/DateItemView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/DateItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/DateItemView.kt index 7cf387a8cc..f0c44a382c 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/DateItemView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/DateItemView.kt @@ -38,7 +38,7 @@ fun DateItemView( @PreviewsDayNight @Composable -internal fun PreviewDateItemView( +internal fun DateItemViewPreview( @PreviewParameter(MediaItemDateSeparatorProvider::class) date: MediaItem.DateSeparator, ) = ElementPreview { DateItemView(date) From 220704077c493b320cbd75774ec379e1fb712152 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 10 Dec 2024 08:46:07 +0100 Subject: [PATCH 08/31] remove blank line. --- .../element/android/libraries/core/extensions/BasicExtensions.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt index f4dd61bf6e..12aa5c4bfe 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt @@ -68,4 +68,3 @@ fun String.replacePrefix(oldPrefix: String, newPrefix: String): String { fun String.withBrackets(prefix: String = "(", suffix: String = ")"): String { return "$prefix$this$suffix" } - From 197fda5d0edfa1b0ecc2947af3d9947a58e83d27 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 10 Dec 2024 08:51:43 +0100 Subject: [PATCH 09/31] Rename onDone to onBackClick. --- .../features/roomdetails/impl/RoomDetailsFlowNode.kt | 2 +- .../libraries/mediaviewer/api/MediaGalleryEntryPoint.kt | 2 +- .../mediaviewer/impl/gallery/MediaGalleryNode.kt | 8 ++++---- .../mediaviewer/impl/gallery/root/MediaGalleryRootNode.kt | 8 ++++---- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt index cf1443fc8b..f89953263c 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt @@ -243,7 +243,7 @@ class RoomDetailsFlowNode @AssistedInject constructor( } is NavTarget.MediaGallery -> { val callback = object : MediaGalleryEntryPoint.Callback { - override fun onDone() { + override fun onBackClick() { backstack.pop() } diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaGalleryEntryPoint.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaGalleryEntryPoint.kt index e8b438b642..a26bb18915 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaGalleryEntryPoint.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaGalleryEntryPoint.kt @@ -22,7 +22,7 @@ interface MediaGalleryEntryPoint : FeatureEntryPoint { } interface Callback : Plugin { - fun onDone() + fun onBackClick() fun onViewInTimeline(eventId: EventId) } } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNode.kt index 4ec91570ef..ccea1a130e 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNode.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNode.kt @@ -31,14 +31,14 @@ class MediaGalleryNode @AssistedInject constructor( ) interface Callback : Plugin { - fun onDone() + fun onBackClick() fun onItemClick(item: MediaItem.Event) fun onViewInTimeline(eventId: EventId) } - private fun onDone() { + private fun onBackClick() { plugins().forEach { - it.onDone() + it.onBackClick() } } @@ -59,7 +59,7 @@ class MediaGalleryNode @AssistedInject constructor( val state = presenter.present() MediaGalleryView( state = state, - onBackClick = ::onDone, + onBackClick = ::onBackClick, onItemClick = ::onItemClick, modifier = modifier, ) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryRootNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryRootNode.kt index 1476cef64a..4f5272b01b 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryRootNode.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryRootNode.kt @@ -67,9 +67,9 @@ class MediaGalleryRootNode @AssistedInject constructor( ) : NavTarget } - private fun onDone() { + private fun onBackClick() { plugins().forEach { - it.onDone() + it.onBackClick() } } @@ -83,8 +83,8 @@ class MediaGalleryRootNode @AssistedInject constructor( return when (navTarget) { NavTarget.Root -> { val callback = object : MediaGalleryNode.Callback { - override fun onDone() { - this@MediaGalleryRootNode.onDone() + override fun onBackClick() { + this@MediaGalleryRootNode.onBackClick() } override fun onViewInTimeline(eventId: EventId) { From 323ae0ad2ee1e7f3ee64bb58c743aab8db8f0340 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 10 Dec 2024 08:57:36 +0100 Subject: [PATCH 10/31] Rename imageItems to imageAndVideoItems. --- .../impl/gallery/MediaGalleryPresenter.kt | 4 ++-- .../impl/gallery/MediaGalleryState.kt | 2 +- .../impl/gallery/MediaGalleryStateProvider.kt | 10 +++++----- .../impl/gallery/MediaGalleryView.kt | 20 +++++++++---------- .../impl/gallery/MediaGalleryPresenterTest.kt | 2 +- 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt index 5bd170f672..dc973be624 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt @@ -77,7 +77,7 @@ class MediaGalleryPresenter @AssistedInject constructor( var mediaItems by remember { mutableStateOf>>(AsyncData.Uninitialized) } - val imageItems by remember { + val imageAndVideoItems by remember { derivedStateOf { mediaItemsPostProcessor.process( mediaItems = mediaItems, @@ -157,7 +157,7 @@ class MediaGalleryPresenter @AssistedInject constructor( return MediaGalleryState( roomName = roomInfo?.name ?: room.displayName, mode = mode, - imageItems = imageItems, + imageAndVideoItems = imageAndVideoItems, fileItems = fileItems, mediaBottomSheetState = mediaBottomSheetState, snackbarMessage = snackbarMessage, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryState.kt index 36e0710a88..bcec4a37ca 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryState.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryState.kt @@ -16,7 +16,7 @@ import kotlinx.collections.immutable.ImmutableList data class MediaGalleryState( val roomName: String, val mode: MediaGalleryMode, - val imageItems: AsyncData>, + val imageAndVideoItems: AsyncData>, val fileItems: AsyncData>, val mediaBottomSheetState: MediaBottomSheetState, val snackbarMessage: SnackbarMessage?, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt index d357ae7a0c..0a48618643 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt @@ -23,10 +23,10 @@ open class MediaGalleryStateProvider : PreviewParameterProvider get() = sequenceOf( aMediaGalleryState(), - aMediaGalleryState(imageItems = AsyncData.Loading()), - aMediaGalleryState(imageItems = AsyncData.Success(emptyList().toPersistentList())), + aMediaGalleryState(imageAndVideoItems = AsyncData.Loading()), + aMediaGalleryState(imageAndVideoItems = AsyncData.Success(emptyList().toPersistentList())), aMediaGalleryState( - imageItems = AsyncData.Success( + imageAndVideoItems = AsyncData.Success( listOf( aDate(id = UniqueId("0")), anImage(id = UniqueId("1")), @@ -71,12 +71,12 @@ open class MediaGalleryStateProvider : PreviewParameterProvider> = AsyncData.Uninitialized, + imageAndVideoItems: AsyncData> = AsyncData.Uninitialized, fileItems: AsyncData> = AsyncData.Uninitialized, ) = MediaGalleryState( roomName = roomName, mode = mode, - imageItems = imageItems, + imageAndVideoItems = imageAndVideoItems, fileItems = fileItems, mediaBottomSheetState = MediaBottomSheetState.Hidden, snackbarMessage = null, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt index ac97755301..3d4082015b 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt @@ -129,7 +129,7 @@ fun MediaGalleryView( val mode = MediaGalleryMode.entries[page] when (mode) { MediaGalleryMode.Images -> MediaGalleryImages( - images = state.imageItems, + imagesAndVideos = state.imageAndVideoItems, eventSink = state.eventSink, onItemClick = onItemClick, ) @@ -180,21 +180,21 @@ fun MediaGalleryView( @Composable private fun MediaGalleryImages( - images: AsyncData>, + imagesAndVideos: AsyncData>, eventSink: (MediaGalleryEvents) -> Unit, onItemClick: (MediaItem.Event) -> Unit, ) { - when (images) { + when (imagesAndVideos) { AsyncData.Uninitialized, is AsyncData.Loading -> { LoadingContent(MediaGalleryMode.Images) } is AsyncData.Success -> { - if (images.data.isEmpty()) { + if (imagesAndVideos.data.isEmpty()) { EmptyContent() } else { MediaGalleryImageGrid( - images = images.data, + imagesAndVideos = imagesAndVideos.data, eventSink = eventSink, onItemClick = onItemClick, ) @@ -202,7 +202,7 @@ private fun MediaGalleryImages( } is AsyncData.Failure -> { ErrorContent( - error = images.error, + error = imagesAndVideos.error, ) } } @@ -275,7 +275,7 @@ private fun MediaGalleryFilesList( @Composable private fun MediaGalleryImageGrid( - images: ImmutableList, + imagesAndVideos: ImmutableList, eventSink: (MediaGalleryEvents) -> Unit, onItemClick: (MediaItem.Event) -> Unit, ) { @@ -296,7 +296,7 @@ private fun MediaGalleryImageGrid( verticalArrangement = Arrangement.spacedBy(4.dp), ) { items( - images, + imagesAndVideos, span = { item -> when (item) { is MediaItem.LoadingIndicator, @@ -318,7 +318,7 @@ private fun MediaGalleryImageGrid( } is MediaItem.Image -> { ImageItemView( - item, + image = item, onClick = { onItemClick(item) }, onLongClick = { eventSink(MediaGalleryEvents.OpenInfo(item)) @@ -327,7 +327,7 @@ private fun MediaGalleryImageGrid( } is MediaItem.Video -> { VideoItemView( - item, + video = item, onClick = { onItemClick(item) }, onLongClick = { eventSink(MediaGalleryEvents.OpenInfo(item)) diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt index 2d6a07e477..c1a71a1152 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt @@ -61,7 +61,7 @@ class MediaGalleryPresenterTest { assertThat(initialState.mode).isEqualTo(MediaGalleryMode.Images) assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) assertThat(initialState.roomName).isEqualTo(A_ROOM_NAME) - assertThat(initialState.imageItems.dataOrNull()).isEmpty() + assertThat(initialState.imageAndVideoItems.dataOrNull()).isEmpty() assertThat(initialState.fileItems.dataOrNull()).isEmpty() assertThat(initialState.snackbarMessage).isNull() } From e55e97795053c76565fcb5ee1784189ea3885dff Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 10 Dec 2024 09:01:42 +0100 Subject: [PATCH 11/31] Remove TimelineMediaItemsCacheInvalidator, it is not needed. --- .../impl/gallery/TimelineMediaItemsFactory.kt | 4 +- .../TimelineMediaItemsCacheInvalidator.kt | 53 ------------------- 2 files changed, 2 insertions(+), 55 deletions(-) delete mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/diff/TimelineMediaItemsCacheInvalidator.kt diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaItemsFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaItemsFactory.kt index 1993381417..d8531cf230 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaItemsFactory.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaItemsFactory.kt @@ -8,12 +8,12 @@ package io.element.android.libraries.mediaviewer.impl.gallery import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.androidutils.diff.DefaultDiffCacheInvalidator import io.element.android.libraries.androidutils.diff.DiffCacheUpdater import io.element.android.libraries.androidutils.diff.MutableListDiffCache import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem -import io.element.android.libraries.mediaviewer.impl.gallery.diff.TimelineMediaItemsCacheInvalidator import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toPersistentList @@ -46,7 +46,7 @@ class DefaultTimelineMediaItemsFactory @Inject constructor( private val diffCacheUpdater = DiffCacheUpdater( diffCache = diffCache, detectMoves = false, - cacheInvalidator = TimelineMediaItemsCacheInvalidator() + cacheInvalidator = DefaultDiffCacheInvalidator() ) { old, new -> if (old is MatrixTimelineItem.Event && new is MatrixTimelineItem.Event) { old.uniqueId == new.uniqueId diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/diff/TimelineMediaItemsCacheInvalidator.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/diff/TimelineMediaItemsCacheInvalidator.kt deleted file mode 100644 index b7e6d51913..0000000000 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/diff/TimelineMediaItemsCacheInvalidator.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright 2024 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only - * Please see LICENSE in the repository root for full details. - */ - -package io.element.android.libraries.mediaviewer.impl.gallery.diff - -import io.element.android.libraries.androidutils.diff.DefaultDiffCacheInvalidator -import io.element.android.libraries.androidutils.diff.DiffCacheInvalidator -import io.element.android.libraries.androidutils.diff.MutableDiffCache -import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem - -/** - * [DiffCacheInvalidator] implementation for [MediaItem]. - * It uses [DefaultDiffCacheInvalidator] and invalidate the cache around the updated item so that those items are computed again. - * This is needed because a timeline item is computed based on the previous and next items. - */ -internal class TimelineMediaItemsCacheInvalidator : DiffCacheInvalidator { - private val delegate = DefaultDiffCacheInvalidator() - - override fun onChanged(position: Int, count: Int, cache: MutableDiffCache) { - delegate.onChanged(position, count, cache) - } - - override fun onMoved(fromPosition: Int, toPosition: Int, cache: MutableDiffCache) { - delegate.onMoved(fromPosition, toPosition, cache) - } - - override fun onInserted(position: Int, count: Int, cache: MutableDiffCache) { - cache.invalidateAround(position) - delegate.onInserted(position, count, cache) - } - - override fun onRemoved(position: Int, count: Int, cache: MutableDiffCache) { - cache.invalidateAround(position) - delegate.onRemoved(position, count, cache) - } -} - -/** - * Invalidate the cache around the given position. - * It invalidates the previous and next items. - */ -private fun MutableDiffCache<*>.invalidateAround(position: Int) { - if (position > 0) { - set(position - 1, null) - } - if (position < indices().last) { - set(position + 1, null) - } -} From a40aff661883c08f547cdb5b88e795848310c8e7 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 10 Dec 2024 09:04:14 +0100 Subject: [PATCH 12/31] Change scope to RoomScope --- .../libraries/mediaviewer/impl/gallery/EventItemFactory.kt | 4 ++-- .../mediaviewer/impl/gallery/MediaItemsPostProcessor.kt | 4 ++-- .../mediaviewer/impl/gallery/TimelineMediaItemsFactory.kt | 4 ++-- .../libraries/mediaviewer/impl/gallery/VirtualItemFactory.kt | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt index d4065f76c2..711ac8c66c 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt @@ -10,7 +10,7 @@ package io.element.android.libraries.mediaviewer.impl.gallery import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.androidutils.filesize.FileSizeFormatter import io.element.android.libraries.dateformatter.api.toHumanReadableDuration -import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent @@ -49,7 +49,7 @@ interface EventItemFactory { fun create(currentTimelineItem: MatrixTimelineItem.Event): MediaItem.Event? } -@ContributesBinding(AppScope::class) +@ContributesBinding(RoomScope::class) class DefaultEventItemFactory @Inject constructor( private val fileSizeFormatter: FileSizeFormatter, private val fileExtensionExtractor: FileExtensionExtractor, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt index d406a34567..88473a8fe2 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt @@ -9,7 +9,7 @@ package io.element.android.libraries.mediaviewer.impl.gallery import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.architecture.AsyncData -import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.RoomScope import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import javax.inject.Inject @@ -21,7 +21,7 @@ interface MediaItemsPostProcessor { ): AsyncData> } -@ContributesBinding(AppScope::class) +@ContributesBinding(RoomScope::class) class DefaultMediaItemsPostProcessor @Inject constructor( ) : MediaItemsPostProcessor { override fun process( diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaItemsFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaItemsFactory.kt index d8531cf230..aca0e6e477 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaItemsFactory.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaItemsFactory.kt @@ -12,7 +12,7 @@ import io.element.android.libraries.androidutils.diff.DefaultDiffCacheInvalidato import io.element.android.libraries.androidutils.diff.DiffCacheUpdater import io.element.android.libraries.androidutils.diff.MutableListDiffCache import io.element.android.libraries.core.coroutine.CoroutineDispatchers -import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.collections.immutable.ImmutableList @@ -33,7 +33,7 @@ interface TimelineMediaItemsFactory { suspend fun onCanPaginate() } -@ContributesBinding(AppScope::class) +@ContributesBinding(RoomScope::class) class DefaultTimelineMediaItemsFactory @Inject constructor( private val dispatchers: CoroutineDispatchers, private val virtualItemFactory: VirtualItemFactory, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/VirtualItemFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/VirtualItemFactory.kt index f8a952cbd4..9f541b6e16 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/VirtualItemFactory.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/VirtualItemFactory.kt @@ -9,7 +9,7 @@ package io.element.android.libraries.mediaviewer.impl.gallery import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.dateformatter.api.DaySeparatorFormatter -import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem import javax.inject.Inject @@ -18,7 +18,7 @@ interface VirtualItemFactory { fun create(timelineItem: MatrixTimelineItem.Virtual): MediaItem? } -@ContributesBinding(AppScope::class) +@ContributesBinding(RoomScope::class) class DefaultVirtualItemFactory @Inject constructor( private val daySeparatorFormatter: DaySeparatorFormatter, ) : VirtualItemFactory { From a89a9c35d8a7716399e310719f4dc331c3a6d70e Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 10 Dec 2024 09:24:31 +0100 Subject: [PATCH 13/31] Remove not needed MediaGalleryTimelineProvider --- libraries/mediaviewer/impl/build.gradle.kts | 2 - .../impl/gallery/MediaGalleryPresenter.kt | 59 +++++---- .../gallery/MediaGalleryTimelineProvider.kt | 113 ------------------ .../impl/gallery/MediaGalleryPresenterTest.kt | 7 -- 4 files changed, 35 insertions(+), 146 deletions(-) delete mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryTimelineProvider.kt diff --git a/libraries/mediaviewer/impl/build.gradle.kts b/libraries/mediaviewer/impl/build.gradle.kts index deeffe9e8b..d77df8ab36 100644 --- a/libraries/mediaviewer/impl/build.gradle.kts +++ b/libraries/mediaviewer/impl/build.gradle.kts @@ -33,7 +33,6 @@ dependencies { implementation(libs.vanniktech.blurhash) implementation(libs.telephoto.flick) - implementation(projects.features.networkmonitor.api) implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.libraries.core) @@ -52,7 +51,6 @@ dependencies { implementation(projects.libraries.di) implementation(projects.libraries.matrix.api) - testImplementation(projects.features.networkmonitor.test) testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.mediaviewer.test) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt index dc973be624..7a70a77e0b 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt @@ -9,6 +9,7 @@ package io.element.android.libraries.mediaviewer.impl.gallery import android.content.ActivityNotFoundException import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf @@ -50,7 +51,6 @@ import kotlinx.coroutines.launch class MediaGalleryPresenter @AssistedInject constructor( @Assisted private val navigator: MediaGalleryNavigator, private val room: MatrixRoom, - private val timelineProvider: MediaGalleryTimelineProvider, private val timelineMediaItemsFactory: TimelineMediaItemsFactory, private val localMediaFactory: LocalMediaFactory, private val mediaLoader: MatrixMediaLoader, @@ -97,27 +97,36 @@ class MediaGalleryPresenter @AssistedInject constructor( val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() localMediaActions.Configure() + var timeline by remember { mutableStateOf>(AsyncData.Uninitialized) } + LaunchedEffect(Unit) { + room.mediaTimeline() + .fold( + { timeline = AsyncData.Success(it) }, + { timeline = AsyncData.Failure(it) }, + ) + } + DisposableEffect(Unit) { + onDispose { + timeline.dataOrNull()?.close() + } + } + MediaListEffect( + timeline = timeline, onItemsChange = { newItems -> mediaItems = newItems } ) - LaunchedEffect(Unit) { - timelineProvider.launchIn(this) - } - fun handleEvents(event: MediaGalleryEvents) { when (event) { is MediaGalleryEvents.ChangeMode -> { mode = event.mode } is MediaGalleryEvents.LoadMore -> coroutineScope.launch { - timelineProvider.invokeOnTimeline { - paginate(event.direction) - } + timeline.dataOrNull()?.paginate(event.direction) } - is MediaGalleryEvents.Delete -> coroutineScope.delete(event.eventId) + is MediaGalleryEvents.Delete -> coroutineScope.delete(timeline, event.eventId) is MediaGalleryEvents.SaveOnDisk -> coroutineScope.saveOnDisk(event.mediaItem) is MediaGalleryEvents.Share -> coroutineScope.share(event.mediaItem) is MediaGalleryEvents.ViewInTimeline -> { @@ -166,18 +175,19 @@ class MediaGalleryPresenter @AssistedInject constructor( } @Composable - private fun MediaListEffect(onItemsChange: (AsyncData>) -> Unit) { + private fun MediaListEffect( + timeline: AsyncData, + onItemsChange: (AsyncData>) -> Unit, + ) { val updatedOnItemsChange by rememberUpdatedState(onItemsChange) - val timelineState by timelineProvider.timelineStateFlow.collectAsState() - - LaunchedEffect(timelineState) { - when (val asyncTimeline = timelineState) { + LaunchedEffect(timeline) { + when (timeline) { AsyncData.Uninitialized -> flowOf(AsyncData.Uninitialized) - is AsyncData.Failure -> flowOf(AsyncData.Failure(asyncTimeline.error)) + is AsyncData.Failure -> flowOf(AsyncData.Failure(timeline.error)) is AsyncData.Loading -> flowOf(AsyncData.Loading()) is AsyncData.Success -> { - asyncTimeline.data.timelineItems + timeline.data.timelineItems .onEach { items -> timelineMediaItemsFactory.replaceWith( timelineItems = items, @@ -185,7 +195,7 @@ class MediaGalleryPresenter @AssistedInject constructor( } .launchIn(this) - asyncTimeline.data.paginationStatus(Timeline.PaginationDirection.BACKWARDS) + timeline.data.paginationStatus(Timeline.PaginationDirection.BACKWARDS) .onEach { backwardPaginationStatus -> if (backwardPaginationStatus.canPaginate) { timelineMediaItemsFactory.onCanPaginate() @@ -205,13 +215,14 @@ class MediaGalleryPresenter @AssistedInject constructor( } } - private fun CoroutineScope.delete(eventId: EventId) = launch { - timelineProvider.invokeOnTimeline { - redactEvent( - eventOrTransactionId = eventId.toEventOrTransactionId(), - reason = null, - ) - } + private fun CoroutineScope.delete( + timeline: AsyncData, + eventId: EventId, + ) = launch { + timeline.dataOrNull()?.redactEvent( + eventOrTransactionId = eventId.toEventOrTransactionId(), + reason = null, + ) } private suspend fun downloadMedia(mediaItem: MediaItem.Event): Result { diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryTimelineProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryTimelineProvider.kt deleted file mode 100644 index 9174cab8ca..0000000000 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryTimelineProvider.kt +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright 2024 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only - * Please see LICENSE in the repository root for full details. - */ - -package io.element.android.libraries.mediaviewer.impl.gallery - -import io.element.android.features.networkmonitor.api.NetworkMonitor -import io.element.android.libraries.architecture.AsyncData -import io.element.android.libraries.core.coroutine.mapState -import io.element.android.libraries.di.RoomScope -import io.element.android.libraries.di.SingleIn -import io.element.android.libraries.featureflag.api.FeatureFlagService -import io.element.android.libraries.featureflag.api.FeatureFlags -import io.element.android.libraries.matrix.api.room.MatrixRoom -import io.element.android.libraries.matrix.api.timeline.Timeline -import io.element.android.libraries.matrix.api.timeline.TimelineProvider -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import javax.inject.Inject - -@SingleIn(RoomScope::class) -class MediaGalleryTimelineProvider @Inject constructor( - private val room: MatrixRoom, - private val networkMonitor: NetworkMonitor, - private val featureFlagService: FeatureFlagService, -) : TimelineProvider { - private val _timelineStateFlow: MutableStateFlow> = - MutableStateFlow(AsyncData.Uninitialized) - - override fun activeTimelineFlow(): StateFlow { - return _timelineStateFlow - .mapState { value -> - value.dataOrNull() - } - } - - val timelineStateFlow = _timelineStateFlow - - fun launchIn(scope: CoroutineScope) { - _timelineStateFlow.subscriptionCount - .map { count -> count > 0 } - .distinctUntilChanged() - .onEach { isActive -> - if (isActive) { - onActive() - } else { - onInactive() - } - } - .launchIn(scope) - } - - private suspend fun onActive() = coroutineScope { - combine( - featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaGallery), - networkMonitor.connectivity - ) { isEnabled, _ -> - // do not use connectivity here as data can be loaded from cache, it's just to trigger retry if needed - isEnabled - } - .onEach { isFeatureEnabled -> - if (isFeatureEnabled) { - loadTimelineIfNeeded() - } else { - resetTimeline() - } - } - .launchIn(this) - } - - private suspend fun onInactive() { - resetTimeline() - } - - private suspend fun resetTimeline() { - invokeOnTimeline { - close() - } - _timelineStateFlow.emit(AsyncData.Uninitialized) - } - - suspend fun invokeOnTimeline(action: suspend Timeline.() -> Unit) { - when (val asyncTimeline = timelineStateFlow.value) { - is AsyncData.Success -> action(asyncTimeline.data) - else -> Unit - } - } - - private suspend fun loadTimelineIfNeeded() { - when (timelineStateFlow.value) { - is AsyncData.Uninitialized, - is AsyncData.Failure -> { - timelineStateFlow.emit(AsyncData.Loading()) - room.mediaTimeline() - .fold( - { timelineStateFlow.emit(AsyncData.Success(it)) }, - { timelineStateFlow.emit(AsyncData.Failure(it)) } - ) - } - else -> Unit - } - } -} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt index c1a71a1152..3acc293f35 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt @@ -9,9 +9,7 @@ package io.element.android.libraries.mediaviewer.impl.gallery import android.net.Uri import com.google.common.truth.Truth.assertThat -import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher -import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem @@ -242,11 +240,6 @@ class MediaGalleryPresenterTest { return MediaGalleryPresenter( navigator = navigator, room = room, - timelineProvider = MediaGalleryTimelineProvider( - room = room, - networkMonitor = FakeNetworkMonitor(), - featureFlagService = FakeFeatureFlagService(), - ), timelineMediaItemsFactory = FakeTimelineMediaItemsFactory( replaceWithLambda = lambdaRecorder, Unit> { _ -> }, onCanPaginateLambda = lambdaRecorder { }, From 6386645e0bbf5ded205e8222fd18c848741763bd Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 10 Dec 2024 09:24:45 +0100 Subject: [PATCH 14/31] Remove extra space. --- .../android/libraries/matrix/test/room/FakeMatrixRoom.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index b357c51fee..41d913b89c 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -204,7 +204,7 @@ class FakeMatrixRoom( pinnedEventsTimelineResult() } - override suspend fun mediaTimeline(): Result = simulateLongTask { + override suspend fun mediaTimeline(): Result = simulateLongTask { mediaTimelineResult() } From cce1c9974a0e3e380547d5556a126ee2e8628a29 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 10 Dec 2024 09:28:46 +0100 Subject: [PATCH 15/31] Format file. --- .../impl/viewer/MediaViewerPresenterTest.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt index aaae1d8b79..2b9e56820d 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt @@ -308,9 +308,12 @@ class MediaViewerPresenterTest { val successState = awaitItem() assertThat(successState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java) successState.eventSink(MediaViewerEvents.Delete(AN_EVENT_ID)) - redactEventLambda.assertions().isCalledOnce().with( - value(AN_EVENT_ID.toEventOrTransactionId()), value(null) - ) + redactEventLambda.assertions() + .isCalledOnce() + .with( + value(AN_EVENT_ID.toEventOrTransactionId()), + value(null), + ) onItemDeletedLambda.assertions().isCalledOnce() } } @@ -372,4 +375,3 @@ class MediaViewerPresenterTest { ) } } - From b78039a17fe6bcb025aa42b8cb4336a183b7365e Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 10 Dec 2024 09:47:53 +0100 Subject: [PATCH 16/31] Add test. --- .../roomdetails/impl/RoomDetailsViewTest.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt index abbca71b53..16b29bfb43 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt @@ -79,6 +79,17 @@ class RoomDetailsViewTest { } } + @Config(qualifiers = "h1024dp") + @Test + fun `click on media gallery invokes expected callback`() { + ensureCalledOnce { callback -> + rule.setRoomDetailView( + openMediaGallery = callback, + ) + rule.clickOn(R.string.screen_room_details_media_gallery_title) + } + } + @Config(qualifiers = "h1024dp") @Test fun `click on notification invokes expected callback`() { @@ -282,6 +293,7 @@ private fun AndroidComposeTestRule.setRoomD invitePeople: () -> Unit = EnsureNeverCalled(), openAvatarPreview: (name: String, url: String) -> Unit = EnsureNeverCalledWithTwoParams(), openPollHistory: () -> Unit = EnsureNeverCalled(), + openMediaGallery: () -> Unit = EnsureNeverCalled(), openAdminSettings: () -> Unit = EnsureNeverCalled(), onJoinCallClick: () -> Unit = EnsureNeverCalled(), onPinnedMessagesClick: () -> Unit = EnsureNeverCalled(), @@ -298,6 +310,7 @@ private fun AndroidComposeTestRule.setRoomD invitePeople = invitePeople, openAvatarPreview = openAvatarPreview, openPollHistory = openPollHistory, + openMediaGallery = openMediaGallery, openAdminSettings = openAdminSettings, onJoinCallClick = onJoinCallClick, onPinnedMessagesClick = onPinnedMessagesClick, From 6dbfa94a71c8445c1370b24d1f47dda61a167602 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 10 Dec 2024 09:56:08 +0100 Subject: [PATCH 17/31] Fix test. --- .../mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt index bd9fe7d884..f60c43572e 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt @@ -11,6 +11,7 @@ import com.google.common.truth.Truth.assertThat import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.media.MediaFile +import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.media.FakeMediaFile import io.element.android.libraries.mediaviewer.api.MediaInfo @@ -27,6 +28,7 @@ class AndroidLocalMediaFactoryTest { fun `test AndroidLocalMediaFactory`() { val sut = createAndroidLocalMediaFactory() val result = sut.createFromMediaFile(aMediaFile(), anImageMediaInfo( + senderId = A_USER_ID, senderName = A_USER_NAME, dateSent = "12:34", )) @@ -38,7 +40,7 @@ class AndroidLocalMediaFactoryTest { mimeType = MimeTypes.Jpeg, formattedFileSize = "4MB", fileExtension = "jpg", - senderId = null, + senderId = A_USER_ID, senderName = A_USER_NAME, senderAvatar = null, dateSent = "12:34" From ff86d0679f14199836558475577f4dcfbaf10f39 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 10 Dec 2024 10:17:57 +0100 Subject: [PATCH 18/31] Fix test. --- .../android/features/roomdetails/impl/RoomDetailsViewTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt index 16b29bfb43..11858929e3 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt @@ -252,7 +252,7 @@ class RoomDetailsViewTest { eventsRecorder.assertSingle(RoomDetailsEvent.SetFavorite(true)) } - @Config(qualifiers = "h1024dp") + @Config(qualifiers = "h1500dp") @Test fun `click on leave emit expected Event`() { val eventsRecorder = EventsRecorder() From 0f43e7c1b86c54757feda6b90ff2993455471942 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 10 Dec 2024 10:42:23 +0100 Subject: [PATCH 19/31] More formatting --- .../mediaviewer/impl/gallery/MediaGalleryPresenter.kt | 1 - .../libraries/mediaviewer/impl/gallery/MediaGalleryView.kt | 4 +--- .../mediaviewer/impl/gallery/MediaItemsPostProcessor.kt | 3 +-- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt index 7a70a77e0b..f5737a27b3 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt @@ -140,7 +140,6 @@ class MediaGalleryPresenter @AssistedInject constructor( null -> false room.sessionId -> room.canRedactOwn().getOrElse { false } && event.mediaItem.eventId() != null else -> room.canRedactOther().getOrElse { false } && event.mediaItem.eventId() != null - }, mediaInfo = event.mediaItem.mediaInfo(), thumbnailSource = when (event.mediaItem) { diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt index 3d4082015b..88b0c5a769 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt @@ -381,8 +381,7 @@ private fun ErrorContent(error: Throwable) { } @Composable -private fun EmptyContent( -) { +private fun EmptyContent() { Box( modifier = Modifier.fillMaxSize(), ) { @@ -433,4 +432,3 @@ internal fun MediaGalleryViewPreview( onItemClick = {}, ) } - diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt index 88473a8fe2..85e972eff2 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt @@ -22,8 +22,7 @@ interface MediaItemsPostProcessor { } @ContributesBinding(RoomScope::class) -class DefaultMediaItemsPostProcessor @Inject constructor( -) : MediaItemsPostProcessor { +class DefaultMediaItemsPostProcessor @Inject constructor() : MediaItemsPostProcessor { override fun process( mediaItems: AsyncData>, predicate: (MediaItem.Event) -> Boolean, From 893c152272fc32200bf0e461569db452c3ae7daa Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 10 Dec 2024 12:28:42 +0100 Subject: [PATCH 20/31] Add missing previews --- .../MediaDeleteConfirmationBottomSheet.kt | 9 +----- .../impl/details/MediaDetailsBottomSheet.kt | 10 +----- .../mediaviewer/impl/details/Preview.kt | 32 +++++++++++++++++++ .../impl/gallery/MediaGalleryStateProvider.kt | 7 +++- 4 files changed, 40 insertions(+), 18 deletions(-) create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/Preview.kt diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheet.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheet.kt index 58b4bfaceb..b8e0075504 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheet.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheet.kt @@ -38,7 +38,6 @@ import io.element.android.libraries.designsystem.theme.components.ModalBottomShe import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.ui.media.MediaRequestData -import io.element.android.libraries.mediaviewer.api.anImageMediaInfo import io.element.android.libraries.mediaviewer.impl.R import io.element.android.libraries.ui.strings.CommonStrings @@ -155,13 +154,7 @@ private fun MediaRow( @Composable internal fun MediaDeleteConfirmationBottomSheetPreview() = ElementPreview { MediaDeleteConfirmationBottomSheet( - state = MediaBottomSheetState.MediaDeleteConfirmationState( - eventId = EventId("\$eventId"), - mediaInfo = anImageMediaInfo( - senderName = "Alice", - ), - thumbnailSource = null, - ), + state = aMediaDeleteConfirmationState(), onDelete = {}, onDismiss = {}, ) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheet.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheet.kt index 9e2109c978..a11abe945b 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheet.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheet.kt @@ -40,7 +40,6 @@ import io.element.android.libraries.designsystem.theme.components.ListItemStyle import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.mediaviewer.api.MediaInfo -import io.element.android.libraries.mediaviewer.api.anImageMediaInfo import io.element.android.libraries.mediaviewer.impl.R import io.element.android.libraries.ui.strings.CommonStrings @@ -195,14 +194,7 @@ private fun SectionText( @Composable internal fun MediaDetailsBottomSheetPreview() = ElementPreview { MediaDetailsBottomSheet( - state = MediaBottomSheetState.MediaDetailsBottomSheetState( - eventId = EventId("\$eventId"), - canDelete = true, - mediaInfo = anImageMediaInfo( - senderName = "Alice", - ), - thumbnailSource = null, - ), + state = aMediaDetailsBottomSheetState(), onViewInTimeline = {}, onDelete = {}, onDismiss = {}, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/Preview.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/Preview.kt new file mode 100644 index 0000000000..880fcb2b91 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/Preview.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE 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(): MediaBottomSheetState.MediaDetailsBottomSheetState { + return MediaBottomSheetState.MediaDetailsBottomSheetState( + eventId = EventId("\$eventId"), + canDelete = true, + mediaInfo = anImageMediaInfo( + senderName = "Alice", + ), + thumbnailSource = null, + ) +} + +fun aMediaDeleteConfirmationState(): MediaBottomSheetState.MediaDeleteConfirmationState { + return MediaBottomSheetState.MediaDeleteConfirmationState( + eventId = EventId("\$eventId"), + mediaInfo = anImageMediaInfo( + senderName = "Alice", + ), + thumbnailSource = null, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt index 0a48618643..7e551b02f4 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt @@ -11,6 +11,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.architecture.AsyncData 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.aMediaDeleteConfirmationState +import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState import io.element.android.libraries.mediaviewer.impl.gallery.ui.aDate import io.element.android.libraries.mediaviewer.impl.gallery.ui.aFile import io.element.android.libraries.mediaviewer.impl.gallery.ui.aVideo @@ -65,6 +67,8 @@ open class MediaGalleryStateProvider : PreviewParameterProvider> = AsyncData.Uninitialized, fileItems: AsyncData> = AsyncData.Uninitialized, + mediaBottomSheetState: MediaBottomSheetState = MediaBottomSheetState.Hidden, ) = MediaGalleryState( roomName = roomName, mode = mode, imageAndVideoItems = imageAndVideoItems, fileItems = fileItems, - mediaBottomSheetState = MediaBottomSheetState.Hidden, + mediaBottomSheetState = mediaBottomSheetState, snackbarMessage = null, eventSink = {} ) From 6fbd34307654659ee0c4e9b719cad406ce43b78e Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 10 Dec 2024 12:58:24 +0100 Subject: [PATCH 21/31] Introduce GroupedMediaItems for code clarity --- .../impl/gallery/MediaGalleryPresenter.kt | 15 +-- .../impl/gallery/MediaGalleryState.kt | 8 +- .../impl/gallery/MediaGalleryStateProvider.kt | 100 ++++++++++-------- .../impl/gallery/MediaGalleryView.kt | 94 ++++++++-------- .../impl/gallery/MediaItemsPostProcessor.kt | 64 ++++++----- .../gallery/FakeMediaItemsPostProcessor.kt | 10 +- .../impl/gallery/MediaGalleryPresenterTest.kt | 21 ++-- 7 files changed, 171 insertions(+), 141 deletions(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt index f5737a27b3..68f2aa98f0 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt @@ -77,23 +77,13 @@ class MediaGalleryPresenter @AssistedInject constructor( var mediaItems by remember { mutableStateOf>>(AsyncData.Uninitialized) } - val imageAndVideoItems by remember { + val groupedMediaItems by remember { derivedStateOf { mediaItemsPostProcessor.process( mediaItems = mediaItems, - predicate = { it is MediaItem.Image || it is MediaItem.Video }, ) } } - val fileItems by remember { - derivedStateOf { - mediaItemsPostProcessor.process( - mediaItems = mediaItems, - predicate = { it is MediaItem.File }, - ) - } - } - val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() localMediaActions.Configure() @@ -165,8 +155,7 @@ class MediaGalleryPresenter @AssistedInject constructor( return MediaGalleryState( roomName = roomInfo?.name ?: room.displayName, mode = mode, - imageAndVideoItems = imageAndVideoItems, - fileItems = fileItems, + groupedMediaItems = groupedMediaItems, mediaBottomSheetState = mediaBottomSheetState, snackbarMessage = snackbarMessage, eventSink = ::handleEvents diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryState.kt index bcec4a37ca..51ae794175 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryState.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryState.kt @@ -16,13 +16,17 @@ import kotlinx.collections.immutable.ImmutableList data class MediaGalleryState( val roomName: String, val mode: MediaGalleryMode, - val imageAndVideoItems: AsyncData>, - val fileItems: AsyncData>, + val groupedMediaItems: AsyncData, val mediaBottomSheetState: MediaBottomSheetState, val snackbarMessage: SnackbarMessage?, val eventSink: (MediaGalleryEvents) -> Unit, ) +data class GroupedMediaItems( + val imageAndVideoItems: ImmutableList, + val fileItems: ImmutableList, +) + enum class MediaGalleryMode(val stringResource: Int) { Images(R.string.screen_media_browser_list_mode_media), Files(R.string.screen_media_browser_list_mode_files), diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt index 7e551b02f4..55fffa2a9c 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt @@ -11,79 +11,91 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.architecture.AsyncData 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.aMediaDeleteConfirmationState import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState import io.element.android.libraries.mediaviewer.impl.gallery.ui.aDate import io.element.android.libraries.mediaviewer.impl.gallery.ui.aFile import io.element.android.libraries.mediaviewer.impl.gallery.ui.aVideo import io.element.android.libraries.mediaviewer.impl.gallery.ui.anImage -import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList -import kotlinx.collections.immutable.toPersistentList open class MediaGalleryStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aMediaGalleryState(), - aMediaGalleryState(imageAndVideoItems = AsyncData.Loading()), - aMediaGalleryState(imageAndVideoItems = AsyncData.Success(emptyList().toPersistentList())), + aMediaGalleryState(groupedMediaItems = AsyncData.Loading()), + aMediaGalleryState(groupedMediaItems = AsyncData.Success(aGroupedMediaItems())), aMediaGalleryState( - imageAndVideoItems = AsyncData.Success( - listOf( - aDate(id = UniqueId("0")), - anImage(id = UniqueId("1")), - aDate( - id = UniqueId("2"), - formattedDate = "September 2004", - ), - anImage(id = UniqueId("3")), - aVideo(id = UniqueId("4")), - anImage(id = UniqueId("5")), - anImage(id = UniqueId("6")), - anImage(id = UniqueId("7")), - anImage(id = UniqueId("8")), - anImage(id = UniqueId("9")), - ).toImmutableList() - ) + groupedMediaItems = AsyncData.Success( + aGroupedMediaItems( + imageAndVideoItems = listOf( + aDate(id = UniqueId("0")), + anImage(id = UniqueId("1")), + aDate( + id = UniqueId("2"), + formattedDate = "September 2004", + ), + anImage(id = UniqueId("3")), + aVideo(id = UniqueId("4")), + anImage(id = UniqueId("5")), + anImage(id = UniqueId("6")), + anImage(id = UniqueId("7")), + anImage(id = UniqueId("8")), + anImage(id = UniqueId("9")), + ).toImmutableList() + ) + ), ), aMediaGalleryState(mode = MediaGalleryMode.Files), - aMediaGalleryState(mode = MediaGalleryMode.Files, fileItems = AsyncData.Loading()), - aMediaGalleryState(mode = MediaGalleryMode.Files, fileItems = AsyncData.Success(emptyList().toPersistentList())), - aMediaGalleryState(mode = MediaGalleryMode.Files, fileItems = AsyncData.Success(emptyList().toPersistentList())), + aMediaGalleryState(mode = MediaGalleryMode.Files, groupedMediaItems = AsyncData.Loading()), + aMediaGalleryState(mode = MediaGalleryMode.Files, groupedMediaItems = AsyncData.Success(aGroupedMediaItems())), aMediaGalleryState( mode = MediaGalleryMode.Files, - fileItems = AsyncData.Success( - listOf( - aDate(id = UniqueId("0")), - aFile(id = UniqueId("1")), - aDate( - id = UniqueId("2"), - formattedDate = "September 2004", - ), - aFile(id = UniqueId("3")), - aFile(id = UniqueId("4")), - aFile(id = UniqueId("5")), - aFile(id = UniqueId("6")), - ).toImmutableList() - ) + groupedMediaItems = AsyncData.Success( + aGroupedMediaItems( + fileItems = listOf( + aDate(id = UniqueId("0")), + aFile(id = UniqueId("1")), + aDate( + id = UniqueId("2"), + formattedDate = "September 2004", + ), + aFile(id = UniqueId("3")), + aFile(id = UniqueId("4")), + aFile(id = UniqueId("5")), + aFile(id = UniqueId("6")), + ).toImmutableList() + ) + ), ), aMediaGalleryState(mediaBottomSheetState = aMediaDetailsBottomSheetState()), - aMediaGalleryState(mediaBottomSheetState = aMediaDeleteConfirmationState()), + aMediaGalleryState( + groupedMediaItems = AsyncData.Failure(Exception("Failed to load media")), + ), + aMediaGalleryState( + mode = MediaGalleryMode.Files, + groupedMediaItems = AsyncData.Failure(Exception("Failed to load media")), + ), ) } private fun aMediaGalleryState( roomName: String = "Room name", mode: MediaGalleryMode = MediaGalleryMode.Images, - imageAndVideoItems: AsyncData> = AsyncData.Uninitialized, - fileItems: AsyncData> = AsyncData.Uninitialized, + groupedMediaItems: AsyncData = AsyncData.Uninitialized, mediaBottomSheetState: MediaBottomSheetState = MediaBottomSheetState.Hidden, ) = MediaGalleryState( roomName = roomName, mode = mode, - imageAndVideoItems = imageAndVideoItems, - fileItems = fileItems, + groupedMediaItems = groupedMediaItems, mediaBottomSheetState = mediaBottomSheetState, snackbarMessage = null, eventSink = {} ) + +private fun aGroupedMediaItems( + imageAndVideoItems: List = emptyList(), + fileItems: List = emptyList(), +) = GroupedMediaItems( + imageAndVideoItems = imageAndVideoItems.toImmutableList(), + fileItems = fileItems.toImmutableList(), +) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt index 88b0c5a769..9e3d89d44e 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt @@ -127,18 +127,11 @@ fun MediaGalleryView( modifier = Modifier, ) { page -> val mode = MediaGalleryMode.entries[page] - when (mode) { - MediaGalleryMode.Images -> MediaGalleryImages( - imagesAndVideos = state.imageAndVideoItems, - eventSink = state.eventSink, - onItemClick = onItemClick, - ) - MediaGalleryMode.Files -> MediaGalleryFiles( - files = state.fileItems, - eventSink = state.eventSink, - onItemClick = onItemClick, - ) - } + MediaGalleryPage( + mode = mode, + state = state, + onItemClick = onItemClick, + ) } } } @@ -179,62 +172,69 @@ fun MediaGalleryView( } @Composable -private fun MediaGalleryImages( - imagesAndVideos: AsyncData>, - eventSink: (MediaGalleryEvents) -> Unit, +private fun MediaGalleryPage( + mode: MediaGalleryMode, + state: MediaGalleryState, onItemClick: (MediaItem.Event) -> Unit, ) { - when (imagesAndVideos) { + when (val groupedMediaItems = state.groupedMediaItems) { AsyncData.Uninitialized, is AsyncData.Loading -> { - LoadingContent(MediaGalleryMode.Images) + LoadingContent(mode) } is AsyncData.Success -> { - if (imagesAndVideos.data.isEmpty()) { - EmptyContent() - } else { - MediaGalleryImageGrid( - imagesAndVideos = imagesAndVideos.data, - eventSink = eventSink, + when (mode) { + MediaGalleryMode.Images -> MediaGalleryImages( + imagesAndVideos = groupedMediaItems.data.imageAndVideoItems, + eventSink = state.eventSink, + onItemClick = onItemClick, + ) + MediaGalleryMode.Files -> MediaGalleryFiles( + files = groupedMediaItems.data.fileItems, + eventSink = state.eventSink, onItemClick = onItemClick, ) } } is AsyncData.Failure -> { ErrorContent( - error = imagesAndVideos.error, + error = groupedMediaItems.error, ) } } } @Composable -private fun MediaGalleryFiles( - files: AsyncData>, +private fun MediaGalleryImages( + imagesAndVideos: ImmutableList, eventSink: (MediaGalleryEvents) -> Unit, onItemClick: (MediaItem.Event) -> Unit, ) { - when (files) { - AsyncData.Uninitialized, - is AsyncData.Loading -> { - LoadingContent(MediaGalleryMode.Files) - } - is AsyncData.Success -> { - if (files.data.isEmpty()) { - EmptyContent() - } else { - MediaGalleryFilesList( - files = files.data, - eventSink = eventSink, - onItemClick = onItemClick, - ) - } - } - is AsyncData.Failure -> { - ErrorContent( - error = files.error, - ) - } + if (imagesAndVideos.isEmpty()) { + EmptyContent() + } else { + MediaGalleryImageGrid( + imagesAndVideos = imagesAndVideos, + eventSink = eventSink, + onItemClick = onItemClick, + ) + } +} + +@Composable +private fun MediaGalleryFiles( + files: ImmutableList, + eventSink: (MediaGalleryEvents) -> Unit, + onItemClick: (MediaItem.Event) -> Unit, +) { + if (files.isEmpty()) { + EmptyContent() + } else { + MediaGalleryFilesList( + files = files, + eventSink = eventSink, + onItemClick = onItemClick, + ) } } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt index 85e972eff2..ff983ac97f 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt @@ -17,54 +17,68 @@ import javax.inject.Inject interface MediaItemsPostProcessor { fun process( mediaItems: AsyncData>, - predicate: (MediaItem.Event) -> Boolean, - ): AsyncData> + ): AsyncData } @ContributesBinding(RoomScope::class) class DefaultMediaItemsPostProcessor @Inject constructor() : MediaItemsPostProcessor { override fun process( mediaItems: AsyncData>, - predicate: (MediaItem.Event) -> Boolean, - ): AsyncData> { + ): AsyncData { return when (mediaItems) { - is AsyncData.Uninitialized -> mediaItems - is AsyncData.Loading -> mediaItems - is AsyncData.Failure -> mediaItems + is AsyncData.Uninitialized -> AsyncData.Uninitialized + is AsyncData.Loading -> AsyncData.Loading() + is AsyncData.Failure -> AsyncData.Failure(mediaItems.error) is AsyncData.Success -> AsyncData.Success( - process( - mediaItems = mediaItems.data, - predicate = predicate, - ) + mediaItems.data.process() ) } } - private fun process( - mediaItems: List, - predicate: (MediaItem.Event) -> Boolean, - ) = buildList { - val eventList = mutableListOf() - for (item in mediaItems) { + private fun List.process(): GroupedMediaItems { + val imageAndVideoItems = mutableListOf() + val fileItems = mutableListOf() + + val imageAndVideoItemsSubList = mutableListOf() + val fileItemsSublist = mutableListOf() + forEach { item -> when (item) { is MediaItem.DateSeparator -> { - if (eventList.isNotEmpty()) { + if (imageAndVideoItemsSubList.isNotEmpty()) { // Date separator first - add(item) + imageAndVideoItems.add(item) // Then events - addAll(eventList) - eventList.clear() + imageAndVideoItems.addAll(imageAndVideoItemsSubList) + imageAndVideoItemsSubList.clear() + } + if (fileItemsSublist.isNotEmpty()) { + // Date separator first + fileItems.add(item) + // Then events + fileItems.addAll(fileItemsSublist) + fileItemsSublist.clear() } } is MediaItem.Event -> { - if (predicate(item)) { - eventList.add(item) + when (item) { + is MediaItem.Image, + is MediaItem.Video -> { + imageAndVideoItemsSubList.add(item) + } + is MediaItem.File -> { + fileItemsSublist.add(item) + } } } is MediaItem.LoadingIndicator -> { - add(item) + imageAndVideoItems.add(item) + fileItems.add(item) } } } - }.toImmutableList() + return GroupedMediaItems( + imageAndVideoItems = imageAndVideoItems.toImmutableList(), + fileItems = fileItems.toImmutableList(), + ) + } } diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaItemsPostProcessor.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaItemsPostProcessor.kt index 637c60d57d..c94fbbbb2c 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaItemsPostProcessor.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaItemsPostProcessor.kt @@ -9,9 +9,15 @@ package io.element.android.libraries.mediaviewer.impl.gallery import io.element.android.libraries.architecture.AsyncData import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf class FakeMediaItemsPostProcessor : MediaItemsPostProcessor { - override fun process(mediaItems: AsyncData>, predicate: (MediaItem.Event) -> Boolean): AsyncData> { - return mediaItems + override fun process(mediaItems: AsyncData>): AsyncData { + return AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(), + fileItems = persistentListOf() + ) + ) } } diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt index 3acc293f35..8867b8ebc3 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt @@ -29,6 +29,7 @@ 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.collections.immutable.persistentListOf import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -54,13 +55,17 @@ class MediaGalleryPresenterTest { ) ) presenter.test { - skipItems(2) + skipItems(1) val initialState = awaitItem() assertThat(initialState.mode).isEqualTo(MediaGalleryMode.Images) assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) assertThat(initialState.roomName).isEqualTo(A_ROOM_NAME) - assertThat(initialState.imageAndVideoItems.dataOrNull()).isEmpty() - assertThat(initialState.fileItems.dataOrNull()).isEmpty() + assertThat(initialState.groupedMediaItems.dataOrNull()).isEqualTo( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(), + fileItems = persistentListOf(), + ) + ) assertThat(initialState.snackbarMessage).isNull() } } @@ -79,7 +84,7 @@ class MediaGalleryPresenterTest { ) ) presenter.test { - skipItems(2) + skipItems(1) val initialState = awaitItem() assertThat(initialState.mode).isEqualTo(MediaGalleryMode.Images) initialState.eventSink(MediaGalleryEvents.ChangeMode(MediaGalleryMode.Files)) @@ -111,7 +116,7 @@ class MediaGalleryPresenterTest { ) ) presenter.test { - skipItems(2) + skipItems(1) val initialState = awaitItem() assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) val item = anImage( @@ -155,7 +160,7 @@ class MediaGalleryPresenterTest { ) ) presenter.test { - skipItems(2) + skipItems(1) val initialState = awaitItem() assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) val item = anImage( @@ -188,7 +193,7 @@ class MediaGalleryPresenterTest { ) ) presenter.test { - skipItems(2) + skipItems(1) val initialState = awaitItem() // Delete bottom sheet val item = anImage() @@ -221,7 +226,7 @@ class MediaGalleryPresenterTest { navigator = navigator, ) presenter.test { - skipItems(2) + skipItems(1) val initialState = awaitItem() initialState.eventSink(MediaGalleryEvents.ViewInTimeline(AN_EVENT_ID)) onViewInTimelineClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID)) From f314c250391ca8c82f7f5a88dcbc67edfe806c57 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 10 Dec 2024 13:04:53 +0100 Subject: [PATCH 22/31] Iterate on Error rendering. --- .../mediaviewer/impl/gallery/MediaGalleryView.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt index 9e3d89d44e..e590d79742 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt @@ -41,6 +41,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.PageTitle +import io.element.android.libraries.designsystem.components.async.AsyncFailure 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 @@ -376,8 +377,11 @@ private fun LoadingMoreIndicator( @Composable private fun ErrorContent(error: Throwable) { - // TODO - Text("Error: $error") + AsyncFailure( + throwable = error, + onRetry = null, + modifier = Modifier.fillMaxSize(), + ) } @Composable From db0bca97219adec7d193739d8eb0953eb936afdd Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 10 Dec 2024 13:10:25 +0100 Subject: [PATCH 23/31] Improve preview. --- .../impl/gallery/MediaGalleryStateProvider.kt | 33 ++++++++++--------- .../impl/gallery/MediaGalleryView.kt | 4 +-- .../impl/gallery/ui/ImageItemView.kt | 2 +- .../impl/gallery/ui/MediaItemFileProvider.kt | 8 ++--- .../impl/gallery/ui/MediaItemImageProvider.kt | 2 +- .../ui/MediaItemLoadingIndicatorProvider.kt | 22 +++++++++++++ .../impl/gallery/ui/MediaItemVideoProvider.kt | 6 ++-- .../impl/gallery/MediaGalleryPresenterTest.kt | 8 ++--- 8 files changed, 53 insertions(+), 32 deletions(-) create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemLoadingIndicatorProvider.kt diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt index 55fffa2a9c..81b34020f5 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt @@ -13,9 +13,10 @@ 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.gallery.ui.aDate -import io.element.android.libraries.mediaviewer.impl.gallery.ui.aFile -import io.element.android.libraries.mediaviewer.impl.gallery.ui.aVideo -import io.element.android.libraries.mediaviewer.impl.gallery.ui.anImage +import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemFile +import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemVideo +import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage +import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemLoadingIndicator import kotlinx.collections.immutable.toImmutableList open class MediaGalleryStateProvider : PreviewParameterProvider { @@ -29,18 +30,19 @@ open class MediaGalleryStateProvider : PreviewParameterProvider GridItemSpan(columns) - is MediaItem.Image, - is MediaItem.Video, - is MediaItem.File -> GridItemSpan(1) + is MediaItem.Event -> GridItemSpan(1) } }, key = { it.id() }, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/ImageItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/ImageItemView.kt index 892b22a951..f92a29c1ae 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/ImageItemView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/ImageItemView.kt @@ -67,7 +67,7 @@ fun ImageItemView( @Composable internal fun ImageItemViewPreview() = ElementPreview { ImageItemView( - image = anImage(), + image = aMediaItemImage(), onClick = {}, onLongClick = {}, ) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemFileProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemFileProvider.kt index f5197e590d..f5374cbbc2 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemFileProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemFileProvider.kt @@ -17,18 +17,18 @@ import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem class MediaItemFileProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( - aFile(), - aFile( + aMediaItemFile(), + aMediaItemFile( filename = "A long filename that should be truncated.jpg", caption = "A caption", ), - aFile( + aMediaItemFile( caption = loremIpsum, ), ) } -fun aFile( +fun aMediaItemFile( id: UniqueId = UniqueId("fileId"), filename: String = "filename", caption: String? = null, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemImageProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemImageProvider.kt index a87983831c..a422fc715b 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemImageProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemImageProvider.kt @@ -14,7 +14,7 @@ import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.mediaviewer.api.anImageMediaInfo import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem -fun anImage( +fun aMediaItemImage( id: UniqueId = UniqueId("imageId"), eventId: EventId? = null, senderId: UserId? = null, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemLoadingIndicatorProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemLoadingIndicatorProvider.kt new file mode 100644 index 0000000000..d9323e5979 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemLoadingIndicatorProvider.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery.ui + +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem + +fun aMediaItemLoadingIndicator( + id: UniqueId = UniqueId("loadingId"), +): MediaItem.LoadingIndicator { + return MediaItem.LoadingIndicator( + id = id, + direction = Timeline.PaginationDirection.BACKWARDS, + timestamp = 123, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVideoProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVideoProvider.kt index 1db99c7e31..1cc223b347 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVideoProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVideoProvider.kt @@ -16,14 +16,14 @@ import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem class MediaItemVideoProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( - aVideo(), - aVideo( + aMediaItemVideo(), + aMediaItemVideo( duration = null, ), ) } -fun aVideo( +fun aMediaItemVideo( id: UniqueId = UniqueId("videoId"), mediaSource: MediaSource = MediaSource(""), duration: String? = "1:23", diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt index 8867b8ebc3..a5fe335fd3 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt @@ -21,7 +21,7 @@ import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState -import io.element.android.libraries.mediaviewer.impl.gallery.ui.anImage +import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory import io.element.android.tests.testutils.WarmUpRule @@ -119,7 +119,7 @@ class MediaGalleryPresenterTest { skipItems(1) val initialState = awaitItem() assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) - val item = anImage( + val item = aMediaItemImage( eventId = AN_EVENT_ID, senderId = A_USER_ID, ) @@ -163,7 +163,7 @@ class MediaGalleryPresenterTest { skipItems(1) val initialState = awaitItem() assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) - val item = anImage( + val item = aMediaItemImage( eventId = AN_EVENT_ID, senderId = A_USER_ID_2, ) @@ -196,7 +196,7 @@ class MediaGalleryPresenterTest { skipItems(1) val initialState = awaitItem() // Delete bottom sheet - val item = anImage() + val item = aMediaItemImage() initialState.eventSink(MediaGalleryEvents.ConfirmDelete(AN_EVENT_ID, item.mediaInfo, item.thumbnailSource)) val deleteState = awaitItem() assertThat(deleteState.mediaBottomSheetState).isEqualTo( From 36eceb75e822c97b2297ce27342862fc9709d700 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 10 Dec 2024 13:34:44 +0100 Subject: [PATCH 24/31] Move bottom state to the View state --- .../impl/viewer/MediaViewerEvents.kt | 3 ++ .../impl/viewer/MediaViewerPresenter.kt | 43 +++++++++++++------ .../impl/viewer/MediaViewerState.kt | 3 +- .../impl/viewer/MediaViewerStateProvider.kt | 12 +++++- .../impl/viewer/MediaViewerView.kt | 22 +++------- .../impl/viewer/MediaViewerPresenterTest.kt | 13 +++--- 6 files changed, 58 insertions(+), 38 deletions(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt index 5b85cd2b9f..6d9a31a816 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt @@ -16,5 +16,8 @@ sealed interface MediaViewerEvents { data object RetryLoading : MediaViewerEvents data object ClearLoadingError : MediaViewerEvents data class ViewInTimeline(val eventId: EventId) : MediaViewerEvents + data object OpenInfo : MediaViewerEvents + data class ConfirmDelete(val eventId: EventId) : MediaViewerEvents + data object CloseBottomSheet : MediaViewerEvents data class Delete(val eventId: EventId) : MediaViewerEvents } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt index a2201aec65..a480d1ba7c 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt @@ -11,11 +11,9 @@ import android.content.ActivityNotFoundException import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -37,6 +35,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTran import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory +import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.CoroutineScope @@ -78,15 +77,7 @@ class MediaViewerPresenter @AssistedInject constructor( mediaFile.value?.close() } } - - val syncUpdateFlow = room.syncUpdateFlow.collectAsState() - val canDelete by produceState(false, syncUpdateFlow.value) { - value = when (inputs.mediaInfo.senderId) { - null -> false - room.sessionId -> room.canRedactOwn().getOrElse { false } && inputs.eventId != null - else -> room.canRedactOther().getOrElse { false } && inputs.eventId != null - } - } + var mediaBottomSheetState by remember { mutableStateOf(MediaBottomSheetState.Hidden) } fun handleEvents(mediaViewerEvents: MediaViewerEvents) { when (mediaViewerEvents) { @@ -95,10 +86,36 @@ class MediaViewerPresenter @AssistedInject constructor( MediaViewerEvents.SaveOnDisk -> coroutineScope.saveOnDisk(localMedia.value) MediaViewerEvents.Share -> coroutineScope.share(localMedia.value) MediaViewerEvents.OpenWith -> coroutineScope.open(localMedia.value) - is MediaViewerEvents.Delete -> coroutineScope.delete(mediaViewerEvents.eventId) + is MediaViewerEvents.Delete -> { + mediaBottomSheetState = MediaBottomSheetState.Hidden + coroutineScope.delete(mediaViewerEvents.eventId) + } is MediaViewerEvents.ViewInTimeline -> { + mediaBottomSheetState = MediaBottomSheetState.Hidden navigator.onViewInTimelineClick(mediaViewerEvents.eventId) } + MediaViewerEvents.OpenInfo -> coroutineScope.launch { + mediaBottomSheetState = MediaBottomSheetState.MediaDetailsBottomSheetState( + eventId = inputs.eventId, + canDelete = when (inputs.mediaInfo.senderId) { + null -> false + room.sessionId -> room.canRedactOwn().getOrElse { false } && inputs.eventId != null + else -> room.canRedactOther().getOrElse { false } && inputs.eventId != null + }, + mediaInfo = inputs.mediaInfo, + thumbnailSource = inputs.thumbnailSource, + ) + } + is MediaViewerEvents.ConfirmDelete -> { + mediaBottomSheetState = MediaBottomSheetState.MediaDeleteConfirmationState( + eventId = mediaViewerEvents.eventId, + mediaInfo = inputs.mediaInfo, + thumbnailSource = inputs.thumbnailSource ?: inputs.mediaSource, + ) + } + MediaViewerEvents.CloseBottomSheet -> { + mediaBottomSheetState = MediaBottomSheetState.Hidden + } } } @@ -111,7 +128,7 @@ class MediaViewerPresenter @AssistedInject constructor( canShowInfo = inputs.canShowInfo, canDownload = inputs.canDownload, canShare = inputs.canShare, - canDelete = canDelete, + mediaBottomSheetState = mediaBottomSheetState, eventSink = ::handleEvents ) } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt index bdd59c5426..6ae8554b06 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt @@ -13,6 +13,7 @@ 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.local.LocalMedia +import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState data class MediaViewerState( val eventId: EventId?, @@ -23,6 +24,6 @@ data class MediaViewerState( val canShowInfo: Boolean, val canDownload: Boolean, val canShare: Boolean, - val canDelete: Boolean, + val mediaBottomSheetState: MediaBottomSheetState, val eventSink: (MediaViewerEvents) -> Unit, ) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt index 003e079b99..8bed5dfc49 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt @@ -17,6 +17,9 @@ import io.element.android.libraries.mediaviewer.api.anApkMediaInfo 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 open class MediaViewerStateProvider : PreviewParameterProvider { override val values: Sequence @@ -91,6 +94,12 @@ open class MediaViewerStateProvider : PreviewParameterProvider canShare = false, ) }, + aMediaViewerState( + mediaBottomSheetState = aMediaDetailsBottomSheetState(), + ), + aMediaViewerState( + mediaBottomSheetState = aMediaDeleteConfirmationState(), + ), ) } @@ -100,6 +109,7 @@ fun aMediaViewerState( canShowInfo: Boolean = true, canDownload: Boolean = true, canShare: Boolean = true, + mediaBottomSheetState: MediaBottomSheetState = MediaBottomSheetState.Hidden, eventSink: (MediaViewerEvents) -> Unit = {}, ) = MediaViewerState( eventId = null, @@ -110,6 +120,6 @@ fun aMediaViewerState( canShowInfo = canShowInfo, canDownload = canDownload, canShare = canShare, - canDelete = true, + mediaBottomSheetState = mediaBottomSheetState, eventSink = eventSink, ) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index f5fae4d3bb..6ca9ebec9c 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -95,7 +95,6 @@ fun MediaViewerView( val defaultBottomPaddingInPixels = if (LocalInspectionMode.current) 303 else 0 var bottomPaddingInPixels by remember { mutableIntStateOf(defaultBottomPaddingInPixels) } BackHandler { onBackClick() } - var mediaBottomSheetState by remember { mutableStateOf(MediaBottomSheetState.Hidden) } Scaffold( modifier, containerColor = Color.Transparent, @@ -128,12 +127,7 @@ fun MediaViewerView( canShowInfo = state.canShowInfo, onBackClick = onBackClick, onInfoClick = { - mediaBottomSheetState = MediaBottomSheetState.MediaDetailsBottomSheetState( - eventId = state.eventId, - canDelete = state.canDelete, - mediaInfo = state.mediaInfo, - thumbnailSource = state.thumbnailSource, - ) + state.eventSink(MediaViewerEvents.OpenInfo) }, eventSink = state.eventSink ) @@ -146,24 +140,19 @@ fun MediaViewerView( } } } - when (val bottomSheetState = mediaBottomSheetState) { + when (val bottomSheetState = state.mediaBottomSheetState) { MediaBottomSheetState.Hidden -> Unit is MediaBottomSheetState.MediaDetailsBottomSheetState -> { MediaDetailsBottomSheet( state = bottomSheetState, onViewInTimeline = { - mediaBottomSheetState = MediaBottomSheetState.Hidden state.eventSink(MediaViewerEvents.ViewInTimeline(it)) }, onDelete = { eventId -> - mediaBottomSheetState = MediaBottomSheetState.MediaDeleteConfirmationState( - eventId = eventId, - mediaInfo = state.mediaInfo, - thumbnailSource = state.thumbnailSource, - ) + state.eventSink(MediaViewerEvents.ConfirmDelete(eventId)) }, onDismiss = { - mediaBottomSheetState = MediaBottomSheetState.Hidden + state.eventSink(MediaViewerEvents.CloseBottomSheet) }, ) } @@ -171,11 +160,10 @@ fun MediaViewerView( MediaDeleteConfirmationBottomSheet( state = bottomSheetState, onDelete = { - mediaBottomSheetState = MediaBottomSheetState.Hidden state.eventSink(MediaViewerEvents.Delete(it)) }, onDismiss = { - mediaBottomSheetState = MediaBottomSheetState.Hidden + state.eventSink(MediaViewerEvents.CloseBottomSheet) }, ) } diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt index 2b9e56820d..43835e71e5 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt @@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.libraries.mediaviewer.api.anApkMediaInfo +import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory import io.element.android.tests.testutils.WarmUpRule @@ -67,7 +68,7 @@ class MediaViewerPresenterTest { assertThat(initialState.canShowInfo).isTrue() assertThat(initialState.canDownload).isTrue() assertThat(initialState.canShare).isTrue() - assertThat(initialState.canDelete).isFalse() + assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) } } @@ -87,7 +88,7 @@ class MediaViewerPresenterTest { assertThat(initialState.canShowInfo).isFalse() assertThat(initialState.canDownload).isTrue() assertThat(initialState.canShare).isTrue() - assertThat(initialState.canDelete).isFalse() + assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) } } @@ -107,7 +108,7 @@ class MediaViewerPresenterTest { assertThat(initialState.canShowInfo).isTrue() assertThat(initialState.canDownload).isTrue() assertThat(initialState.canShare).isFalse() - assertThat(initialState.canDelete).isFalse() + assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) } } @@ -127,7 +128,7 @@ class MediaViewerPresenterTest { assertThat(initialState.canShowInfo).isTrue() assertThat(initialState.canDownload).isFalse() assertThat(initialState.canShare).isTrue() - assertThat(initialState.canDelete).isFalse() + assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) } } @@ -147,7 +148,7 @@ class MediaViewerPresenterTest { assertThat(initialState.canShowInfo).isTrue() assertThat(initialState.canDownload).isTrue() assertThat(initialState.canShare).isTrue() - assertThat(initialState.canDelete).isTrue() + assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) } } @@ -168,7 +169,7 @@ class MediaViewerPresenterTest { assertThat(initialState.canShowInfo).isTrue() assertThat(initialState.canDownload).isTrue() assertThat(initialState.canShare).isTrue() - assertThat(initialState.canDelete).isFalse() + assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) } } From e5b34216e9c6ac83ddb808089912b6db8db865b6 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 10 Dec 2024 14:39:10 +0100 Subject: [PATCH 25/31] Media timeline: improve pagination logic. --- .../matrix/impl/timeline/RustTimeline.kt | 57 ++++++++----------- .../impl/gallery/MediaGalleryPresenter.kt | 8 --- .../impl/gallery/TimelineMediaItemsFactory.kt | 25 -------- .../gallery/FakeTimelineMediaItemsFactory.kt | 5 -- .../impl/gallery/MediaGalleryPresenterTest.kt | 1 - 5 files changed, 23 insertions(+), 73 deletions(-) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt index 1a9da807a0..c8e8b77b32 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt @@ -56,6 +56,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.launchIn @@ -64,8 +65,6 @@ import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.EditedContent import org.matrix.rustcomponents.sdk.FormattedBody @@ -172,36 +171,26 @@ class RustTimeline( } } - private val backwardsPaginationMutex = Mutex() - private val forwardsPaginationMutex = Mutex() - - private fun getPaginationMutex(direction: Timeline.PaginationDirection) = when (direction) { - Timeline.PaginationDirection.BACKWARDS -> backwardsPaginationMutex - Timeline.PaginationDirection.FORWARDS -> forwardsPaginationMutex - } - // Use NonCancellable to avoid breaking the timeline when the coroutine is cancelled. override suspend fun paginate(direction: Timeline.PaginationDirection): Result = withContext(NonCancellable) { withContext(dispatcher) { initLatch.await() - getPaginationMutex(direction).withLock { - runCatching { - if (!canPaginate(direction)) throw TimelineException.CannotPaginate - updatePaginationStatus(direction) { it.copy(isPaginating = true) } - when (direction) { - Timeline.PaginationDirection.BACKWARDS -> inner.paginateBackwards(PAGINATION_SIZE.toUShort()) - Timeline.PaginationDirection.FORWARDS -> inner.focusedPaginateForwards(PAGINATION_SIZE.toUShort()) - } - }.onFailure { error -> - updatePaginationStatus(direction) { it.copy(isPaginating = false) } - if (error is TimelineException.CannotPaginate) { - Timber.d("Can't paginate $direction on room ${matrixRoom.roomId} with paginationStatus: ${backPaginationStatus.value}") - } else { - Timber.e(error, "Error paginating $direction on room ${matrixRoom.roomId}") - } - }.onSuccess { hasReachedEnd -> - updatePaginationStatus(direction) { it.copy(isPaginating = false, hasMoreToLoad = !hasReachedEnd) } + runCatching { + if (!canPaginate(direction)) throw TimelineException.CannotPaginate + updatePaginationStatus(direction) { it.copy(isPaginating = true) } + when (direction) { + Timeline.PaginationDirection.BACKWARDS -> inner.paginateBackwards(PAGINATION_SIZE.toUShort()) + Timeline.PaginationDirection.FORWARDS -> inner.focusedPaginateForwards(PAGINATION_SIZE.toUShort()) } + }.onFailure { error -> + if (error is TimelineException.CannotPaginate) { + Timber.d("Can't paginate $direction on room ${matrixRoom.roomId} with paginationStatus: ${backPaginationStatus.value}") + } else { + updatePaginationStatus(direction) { it.copy(isPaginating = false) } + Timber.e(error, "Error paginating $direction on room ${matrixRoom.roomId}") + } + }.onSuccess { hasReachedEnd -> + updatePaginationStatus(direction) { it.copy(isPaginating = false, hasMoreToLoad = !hasReachedEnd) } } } } @@ -223,13 +212,13 @@ class RustTimeline( override val timelineItems: Flow> = combine( _timelineItems, - backPaginationStatus.map { it.hasMoreToLoad }.distinctUntilChanged(), - forwardPaginationStatus.map { it.hasMoreToLoad }.distinctUntilChanged(), + backPaginationStatus.filter { !it.isPaginating }.distinctUntilChanged(), + forwardPaginationStatus.filter { !it.isPaginating }.distinctUntilChanged(), matrixRoom.roomInfoFlow.map { it.creator }, isTimelineInitialized, ) { timelineItems, - hasMoreToLoadBackward, - hasMoreToLoadForward, + backwardPaginationStatus, + forwardPaginationStatus, roomCreator, isTimelineInitialized -> withContext(dispatcher) { @@ -239,15 +228,15 @@ class RustTimeline( items = items, isDm = matrixRoom.isDm, roomCreator = roomCreator, - hasMoreToLoadBackwards = hasMoreToLoadBackward, + hasMoreToLoadBackwards = backwardPaginationStatus.hasMoreToLoad, ) } .let { items -> loadingIndicatorsPostProcessor.process( items = items, isTimelineInitialized = isTimelineInitialized, - hasMoreToLoadBackward = hasMoreToLoadBackward, - hasMoreToLoadForward = hasMoreToLoadForward + hasMoreToLoadBackward = backwardPaginationStatus.hasMoreToLoad, + hasMoreToLoadForward = forwardPaginationStatus.hasMoreToLoad, ) } .let { items -> diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt index 68f2aa98f0..c122e95447 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt @@ -183,14 +183,6 @@ class MediaGalleryPresenter @AssistedInject constructor( } .launchIn(this) - timeline.data.paginationStatus(Timeline.PaginationDirection.BACKWARDS) - .onEach { backwardPaginationStatus -> - if (backwardPaginationStatus.canPaginate) { - timelineMediaItemsFactory.onCanPaginate() - } - } - .launchIn(this) - timelineMediaItemsFactory.timelineItems.map { timelineItems -> AsyncData.Success(timelineItems) } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaItemsFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaItemsFactory.kt index aca0e6e477..abad9d2052 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaItemsFactory.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaItemsFactory.kt @@ -14,7 +14,6 @@ import io.element.android.libraries.androidutils.diff.MutableListDiffCache import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem -import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.flow.Flow @@ -23,14 +22,11 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import timber.log.Timber import javax.inject.Inject interface TimelineMediaItemsFactory { val timelineItems: Flow> - suspend fun replaceWith(timelineItems: List) - suspend fun onCanPaginate() } @ContributesBinding(RoomScope::class) @@ -38,7 +34,6 @@ class DefaultTimelineMediaItemsFactory @Inject constructor( private val dispatchers: CoroutineDispatchers, private val virtualItemFactory: VirtualItemFactory, private val eventItemFactory: EventItemFactory, - private val systemClock: SystemClock, ) : TimelineMediaItemsFactory { private val _timelineItems = MutableSharedFlow>(replay = 1) private val lock = Mutex() @@ -66,26 +61,6 @@ class DefaultTimelineMediaItemsFactory @Inject constructor( } } - /** - * Update the timestamp of the loading indicator, so that it may trigger a new pagination request. - */ - override suspend fun onCanPaginate() { - lock.withLock { - val values = _timelineItems.replayCache.firstOrNull() ?: return@withLock - val lastItem = values.lastOrNull() - if (lastItem is MediaItem.LoadingIndicator) { - val newList = values.toMutableList().apply { - removeAt(size - 1) - val newTs = systemClock.epochMillis() - add(lastItem.copy(timestamp = newTs)) - } - _timelineItems.emit(newList.toPersistentList()) - } else { - Timber.w("onCanPaginate called but last item is not a loading indicator") - } - } - } - private suspend fun buildAndEmitTimelineItemStates( timelineItems: List, ) { diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeTimelineMediaItemsFactory.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeTimelineMediaItemsFactory.kt index 618ba855ad..c9c95230db 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeTimelineMediaItemsFactory.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeTimelineMediaItemsFactory.kt @@ -16,7 +16,6 @@ import kotlinx.coroutines.flow.flowOf class FakeTimelineMediaItemsFactory( private val replaceWithLambda: (List) -> Unit = { lambdaError() }, - private val onCanPaginateLambda: () -> Unit = { lambdaError() } ) : TimelineMediaItemsFactory { override val timelineItems: Flow> get() = flowOf(emptyList().toImmutableList()) @@ -24,8 +23,4 @@ class FakeTimelineMediaItemsFactory( override suspend fun replaceWith(timelineItems: List) { replaceWithLambda(timelineItems) } - - override suspend fun onCanPaginate() { - onCanPaginateLambda() - } } diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt index a5fe335fd3..39c88a5597 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt @@ -247,7 +247,6 @@ class MediaGalleryPresenterTest { room = room, timelineMediaItemsFactory = FakeTimelineMediaItemsFactory( replaceWithLambda = lambdaRecorder, Unit> { _ -> }, - onCanPaginateLambda = lambdaRecorder { }, ), localMediaFactory = localMediaFactory, mediaLoader = matrixMediaLoader, From 1b7d3a33b7b448e6b4c8bd555a53a64f53f66fb3 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 10 Dec 2024 13:58:46 +0100 Subject: [PATCH 26/31] Add test on DefaultEventItemFactory --- .../gallery/DefaultEventItemFactoryTest.kt | 410 ++++++++++++++++++ 1 file changed, 410 insertions(+) create mode 100644 libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/DefaultEventItemFactoryTest.kt diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/DefaultEventItemFactoryTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/DefaultEventItemFactoryTest.kt new file mode 100644 index 0000000000..0fe977ff8a --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/DefaultEventItemFactoryTest.kt @@ -0,0 +1,410 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.media.AudioDetails +import io.element.android.libraries.matrix.api.media.AudioInfo +import io.element.android.libraries.matrix.api.media.FileInfo +import io.element.android.libraries.matrix.api.media.ImageInfo +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.media.VideoInfo +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent +import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent +import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent +import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent +import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.OtherState +import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent +import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent +import io.element.android.libraries.matrix.api.timeline.item.event.StateContent +import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent +import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent +import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_UNIQUE_ID +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.timeline.aMessageContent +import io.element.android.libraries.matrix.test.timeline.aPollContent +import io.element.android.libraries.matrix.test.timeline.aProfileChangeMessageContent +import io.element.android.libraries.matrix.test.timeline.aStickerContent +import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation +import kotlinx.collections.immutable.persistentListOf +import org.junit.Test +import kotlin.time.Duration.Companion.seconds + +class DefaultEventItemFactoryTest { + @Test + fun `create check all null cases`() { + val factory = createDefaultEventItemFactory() + val contents = listOf( + CallNotifyContent, + FailedToParseMessageLikeContent("", ""), + FailedToParseStateContent("", "", ""), + LegacyCallInviteContent, + aPollContent(), + aProfileChangeMessageContent(), + RedactedContent, + RoomMembershipContent( + userId = A_USER_ID, + userDisplayName = null, + change = null, + ), + StateContent("", OtherState.RoomCreate), + aStickerContent( + info = ImageInfo( + width = null, + height = null, + mimetype = null, + size = null, + thumbnailInfo = null, + thumbnailSource = null, + blurhash = null, + ), + mediaSource = MediaSource("") + ), + UnableToDecryptContent(UnableToDecryptContent.Data.Unknown), + UnknownContent, + ) + contents.forEach { + val result = factory.create( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + content = it + ) + ) + ) + assertThat(result).isNull() + } + } + + @Test + fun `create MessageContent check all null cases`() { + val factory = createDefaultEventItemFactory() + val messageTypes = listOf( + EmoteMessageType("", null), + NoticeMessageType("", null), + OtherMessageType("", ""), + LocationMessageType("", "", null), + TextMessageType("", null) + ) + messageTypes.forEach { + val result = factory.create( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + content = aMessageContent( + messageType = it + ) + ) + ) + ) + assertThat(result).isNull() + } + } + + @Test + fun `create for FileMessageType`() { + val factory = createDefaultEventItemFactory() + val result = factory.create( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + content = aMessageContent( + messageType = FileMessageType( + filename = "filename.apk", + caption = "caption", + formattedCaption = null, + source = MediaSource(""), + info = FileInfo( + mimetype = MimeTypes.Apk, + size = 123L, + thumbnailInfo = null, + thumbnailSource = null, + ) + ) + ) + ) + ) + ) + assertThat(result).isEqualTo( + MediaItem.File( + id = A_UNIQUE_ID, + eventId = AN_EVENT_ID, + mediaInfo = MediaInfo( + mimeType = MimeTypes.Apk, + filename = "filename.apk", + caption = "caption", + formattedFileSize = "123 Bytes", + fileExtension = "apk", + senderId = A_USER_ID, + senderName = "alice", + senderAvatar = null, + dateSent = "1 Jan 1970", + ), + mediaSource = MediaSource(""), + ) + ) + } + + @Test + fun `create for ImageMessageType`() { + val factory = createDefaultEventItemFactory() + val result = factory.create( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + content = aMessageContent( + messageType = ImageMessageType( + filename = "filename.jpg", + caption = "caption", + formattedCaption = null, + source = MediaSource(""), + info = ImageInfo( + mimetype = MimeTypes.Jpeg, + size = 123L, + thumbnailInfo = null, + thumbnailSource = null, + height = 1L, + width = 2L, + blurhash = null, + ) + ) + ) + ) + ) + ) + assertThat(result).isEqualTo( + MediaItem.Image( + id = A_UNIQUE_ID, + eventId = AN_EVENT_ID, + mediaInfo = MediaInfo( + mimeType = MimeTypes.Jpeg, + filename = "filename.jpg", + caption = "caption", + formattedFileSize = "123 Bytes", + fileExtension = "jpg", + senderId = A_USER_ID, + senderName = "alice", + senderAvatar = null, + dateSent = "1 Jan 1970", + ), + mediaSource = MediaSource(""), + thumbnailSource = null, + ) + ) + } + + @Test + fun `create for AudioMessageType`() { + val factory = createDefaultEventItemFactory() + val result = factory.create( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + content = aMessageContent( + messageType = AudioMessageType( + filename = "filename.mp3", + caption = "caption", + formattedCaption = null, + source = MediaSource(""), + info = AudioInfo( + mimetype = MimeTypes.Mp3, + size = 123L, + duration = 456.seconds, + ) + ) + ) + ) + ) + ) + assertThat(result).isEqualTo( + MediaItem.File( + id = A_UNIQUE_ID, + eventId = AN_EVENT_ID, + mediaInfo = MediaInfo( + mimeType = MimeTypes.Mp3, + filename = "filename.mp3", + caption = "caption", + formattedFileSize = "123 Bytes", + fileExtension = "mp3", + senderId = A_USER_ID, + senderName = "alice", + senderAvatar = null, + dateSent = "1 Jan 1970", + ), + mediaSource = MediaSource(""), + ) + ) + } + + @Test + fun `create for VideoMessageType`() { + val factory = createDefaultEventItemFactory() + val result = factory.create( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + content = aMessageContent( + messageType = VideoMessageType( + filename = "filename.mp4", + caption = "caption", + formattedCaption = null, + source = MediaSource(""), + info = VideoInfo( + mimetype = MimeTypes.Mp4, + size = 123L, + thumbnailInfo = null, + duration = 123.seconds, + height = 1L, + width = 2L, + thumbnailSource = null, + blurhash = null + ) + ) + ) + ) + ) + ) + assertThat(result).isEqualTo( + MediaItem.Video( + id = A_UNIQUE_ID, + eventId = AN_EVENT_ID, + mediaInfo = MediaInfo( + mimeType = MimeTypes.Mp4, + filename = "filename.mp4", + caption = "caption", + formattedFileSize = "123 Bytes", + fileExtension = "mp4", + senderId = A_USER_ID, + senderName = "alice", + senderAvatar = null, + dateSent = "1 Jan 1970", + ), + mediaSource = MediaSource(""), + thumbnailSource = null, + duration = "2:03", + ) + ) + } + + @Test + fun `create for VoiceMessageType`() { + val factory = createDefaultEventItemFactory() + val result = factory.create( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + content = aMessageContent( + messageType = VoiceMessageType( + filename = "filename.ogg", + caption = "caption", + formattedCaption = null, + source = MediaSource(""), + info = AudioInfo( + mimetype = MimeTypes.Ogg, + size = 123L, + duration = 456.seconds, + ), + details = AudioDetails( + duration = 456.seconds, + waveform = persistentListOf(), + ) + ) + ) + ) + ) + ) + assertThat(result).isEqualTo( + MediaItem.File( + id = A_UNIQUE_ID, + eventId = AN_EVENT_ID, + mediaInfo = MediaInfo( + mimeType = MimeTypes.Ogg, + filename = "filename.ogg", + caption = "caption", + formattedFileSize = "123 Bytes", + fileExtension = "ogg", + senderId = A_USER_ID, + senderName = "alice", + senderAvatar = null, + dateSent = "1 Jan 1970", + ), + mediaSource = MediaSource(""), + ) + ) + } + + @Test + fun `create for StickerMessageType`() { + val factory = createDefaultEventItemFactory() + val result = factory.create( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + content = aMessageContent( + messageType = StickerMessageType( + filename = "filename.gif", + caption = "caption", + formattedCaption = null, + source = MediaSource(""), + info = ImageInfo( + mimetype = MimeTypes.Gif, + size = 123L, + thumbnailInfo = null, + thumbnailSource = null, + height = 1L, + width = 2L, + blurhash = null, + ) + ) + ) + ) + ) + ) + assertThat(result).isEqualTo( + MediaItem.Image( + id = A_UNIQUE_ID, + eventId = AN_EVENT_ID, + mediaInfo = MediaInfo( + mimeType = MimeTypes.Gif, + filename = "filename.gif", + caption = "caption", + formattedFileSize = "123 Bytes", + fileExtension = "gif", + senderId = A_USER_ID, + senderName = "alice", + senderAvatar = null, + dateSent = "1 Jan 1970", + ), + mediaSource = MediaSource(""), + thumbnailSource = null, + ) + ) + } +} + +private fun createDefaultEventItemFactory() = DefaultEventItemFactory( + fileSizeFormatter = FakeFileSizeFormatter(), + fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), +) From bb06b6811002730c1eb876202337ece73ccaf306 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 10 Dec 2024 15:56:54 +0100 Subject: [PATCH 27/31] Let the Presenter use real classes. --- libraries/mediaviewer/impl/build.gradle.kts | 1 + .../impl/gallery/EventItemFactory.kt | 13 ++----- .../impl/gallery/MediaItemsPostProcessor.kt | 11 +----- .../impl/gallery/TimelineMediaItemsFactory.kt | 16 ++------ .../impl/gallery/VirtualItemFactory.kt | 13 ++----- .../gallery/DefaultEventItemFactoryTest.kt | 18 ++++----- .../impl/gallery/FakeEventItemFactory.kt | 16 -------- .../gallery/FakeMediaItemsPostProcessor.kt | 23 ------------ .../gallery/FakeTimelineMediaItemsFactory.kt | 26 ------------- .../impl/gallery/FakeVirtualItemFactory.kt | 16 -------- .../impl/gallery/MediaGalleryPresenterTest.kt | 37 ++++++++++++------- 11 files changed, 45 insertions(+), 145 deletions(-) delete mode 100644 libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeEventItemFactory.kt delete mode 100644 libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaItemsPostProcessor.kt delete mode 100644 libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeTimelineMediaItemsFactory.kt delete mode 100644 libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeVirtualItemFactory.kt diff --git a/libraries/mediaviewer/impl/build.gradle.kts b/libraries/mediaviewer/impl/build.gradle.kts index d77df8ab36..4fa63820d3 100644 --- a/libraries/mediaviewer/impl/build.gradle.kts +++ b/libraries/mediaviewer/impl/build.gradle.kts @@ -51,6 +51,7 @@ dependencies { implementation(projects.libraries.di) implementation(projects.libraries.matrix.api) + testImplementation(projects.libraries.dateformatter.test) testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.mediaviewer.test) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt index 711ac8c66c..15848d85a1 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt @@ -7,10 +7,8 @@ package io.element.android.libraries.mediaviewer.impl.gallery -import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.androidutils.filesize.FileSizeFormatter import io.element.android.libraries.dateformatter.api.toHumanReadableDuration -import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent @@ -45,18 +43,13 @@ import java.text.DateFormat import java.util.Date import javax.inject.Inject -interface EventItemFactory { - fun create(currentTimelineItem: MatrixTimelineItem.Event): MediaItem.Event? -} - -@ContributesBinding(RoomScope::class) -class DefaultEventItemFactory @Inject constructor( +class EventItemFactory @Inject constructor( private val fileSizeFormatter: FileSizeFormatter, private val fileExtensionExtractor: FileExtensionExtractor, -) : EventItemFactory { +) { private val timeFormatter = DateFormat.getDateInstance() - override fun create( + fun create( currentTimelineItem: MatrixTimelineItem.Event, ): MediaItem.Event? { val event = currentTimelineItem.event diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt index ff983ac97f..5693fbc8c4 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt @@ -7,23 +7,14 @@ package io.element.android.libraries.mediaviewer.impl.gallery -import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.architecture.AsyncData -import io.element.android.libraries.di.RoomScope import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import javax.inject.Inject -interface MediaItemsPostProcessor { +class MediaItemsPostProcessor @Inject constructor() { fun process( mediaItems: AsyncData>, - ): AsyncData -} - -@ContributesBinding(RoomScope::class) -class DefaultMediaItemsPostProcessor @Inject constructor() : MediaItemsPostProcessor { - override fun process( - mediaItems: AsyncData>, ): AsyncData { return when (mediaItems) { is AsyncData.Uninitialized -> AsyncData.Uninitialized diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaItemsFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaItemsFactory.kt index abad9d2052..79fcb8fd99 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaItemsFactory.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaItemsFactory.kt @@ -7,12 +7,10 @@ package io.element.android.libraries.mediaviewer.impl.gallery -import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.androidutils.diff.DefaultDiffCacheInvalidator import io.element.android.libraries.androidutils.diff.DiffCacheUpdater import io.element.android.libraries.androidutils.diff.MutableListDiffCache import io.element.android.libraries.core.coroutine.CoroutineDispatchers -import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toPersistentList @@ -24,17 +22,11 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import javax.inject.Inject -interface TimelineMediaItemsFactory { - val timelineItems: Flow> - suspend fun replaceWith(timelineItems: List) -} - -@ContributesBinding(RoomScope::class) -class DefaultTimelineMediaItemsFactory @Inject constructor( +class TimelineMediaItemsFactory @Inject constructor( private val dispatchers: CoroutineDispatchers, private val virtualItemFactory: VirtualItemFactory, private val eventItemFactory: EventItemFactory, -) : TimelineMediaItemsFactory { +) { private val _timelineItems = MutableSharedFlow>(replay = 1) private val lock = Mutex() private val diffCache = MutableListDiffCache() @@ -50,9 +42,9 @@ class DefaultTimelineMediaItemsFactory @Inject constructor( } } - override val timelineItems: Flow> = _timelineItems.distinctUntilChanged() + val timelineItems: Flow> = _timelineItems.distinctUntilChanged() - override suspend fun replaceWith( + suspend fun replaceWith( timelineItems: List, ) = withContext(dispatchers.computation) { lock.withLock { diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/VirtualItemFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/VirtualItemFactory.kt index 9f541b6e16..22d5ef546b 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/VirtualItemFactory.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/VirtualItemFactory.kt @@ -7,22 +7,15 @@ package io.element.android.libraries.mediaviewer.impl.gallery -import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.dateformatter.api.DaySeparatorFormatter -import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem import javax.inject.Inject -interface VirtualItemFactory { - fun create(timelineItem: MatrixTimelineItem.Virtual): MediaItem? -} - -@ContributesBinding(RoomScope::class) -class DefaultVirtualItemFactory @Inject constructor( +class VirtualItemFactory @Inject constructor( private val daySeparatorFormatter: DaySeparatorFormatter, -) : VirtualItemFactory { - override fun create(timelineItem: MatrixTimelineItem.Virtual): MediaItem? { +) { + fun create(timelineItem: MatrixTimelineItem.Virtual): MediaItem? { return when (val virtual = timelineItem.virtual) { is VirtualTimelineItem.DayDivider -> MediaItem.DateSeparator( id = timelineItem.uniqueId, diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/DefaultEventItemFactoryTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/DefaultEventItemFactoryTest.kt index 0fe977ff8a..487c30dbbf 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/DefaultEventItemFactoryTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/DefaultEventItemFactoryTest.kt @@ -55,7 +55,7 @@ import kotlin.time.Duration.Companion.seconds class DefaultEventItemFactoryTest { @Test fun `create check all null cases`() { - val factory = createDefaultEventItemFactory() + val factory = createEventItemFactory() val contents = listOf( CallNotifyContent, FailedToParseMessageLikeContent("", ""), @@ -100,7 +100,7 @@ class DefaultEventItemFactoryTest { @Test fun `create MessageContent check all null cases`() { - val factory = createDefaultEventItemFactory() + val factory = createEventItemFactory() val messageTypes = listOf( EmoteMessageType("", null), NoticeMessageType("", null), @@ -125,7 +125,7 @@ class DefaultEventItemFactoryTest { @Test fun `create for FileMessageType`() { - val factory = createDefaultEventItemFactory() + val factory = createEventItemFactory() val result = factory.create( MatrixTimelineItem.Event( uniqueId = A_UNIQUE_ID, @@ -169,7 +169,7 @@ class DefaultEventItemFactoryTest { @Test fun `create for ImageMessageType`() { - val factory = createDefaultEventItemFactory() + val factory = createEventItemFactory() val result = factory.create( MatrixTimelineItem.Event( uniqueId = A_UNIQUE_ID, @@ -217,7 +217,7 @@ class DefaultEventItemFactoryTest { @Test fun `create for AudioMessageType`() { - val factory = createDefaultEventItemFactory() + val factory = createEventItemFactory() val result = factory.create( MatrixTimelineItem.Event( uniqueId = A_UNIQUE_ID, @@ -260,7 +260,7 @@ class DefaultEventItemFactoryTest { @Test fun `create for VideoMessageType`() { - val factory = createDefaultEventItemFactory() + val factory = createEventItemFactory() val result = factory.create( MatrixTimelineItem.Event( uniqueId = A_UNIQUE_ID, @@ -310,7 +310,7 @@ class DefaultEventItemFactoryTest { @Test fun `create for VoiceMessageType`() { - val factory = createDefaultEventItemFactory() + val factory = createEventItemFactory() val result = factory.create( MatrixTimelineItem.Event( uniqueId = A_UNIQUE_ID, @@ -357,7 +357,7 @@ class DefaultEventItemFactoryTest { @Test fun `create for StickerMessageType`() { - val factory = createDefaultEventItemFactory() + val factory = createEventItemFactory() val result = factory.create( MatrixTimelineItem.Event( uniqueId = A_UNIQUE_ID, @@ -404,7 +404,7 @@ class DefaultEventItemFactoryTest { } } -private fun createDefaultEventItemFactory() = DefaultEventItemFactory( +private fun createEventItemFactory() = EventItemFactory( fileSizeFormatter = FakeFileSizeFormatter(), fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), ) diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeEventItemFactory.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeEventItemFactory.kt deleted file mode 100644 index 6462204721..0000000000 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeEventItemFactory.kt +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright 2024 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only - * Please see LICENSE in the repository root for full details. - */ - -package io.element.android.libraries.mediaviewer.impl.gallery - -import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem - -class FakeEventItemFactory : EventItemFactory { - override fun create(currentTimelineItem: MatrixTimelineItem.Event): MediaItem.Event? { - return null - } -} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaItemsPostProcessor.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaItemsPostProcessor.kt deleted file mode 100644 index c94fbbbb2c..0000000000 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaItemsPostProcessor.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright 2024 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only - * Please see LICENSE in the repository root for full details. - */ - -package io.element.android.libraries.mediaviewer.impl.gallery - -import io.element.android.libraries.architecture.AsyncData -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf - -class FakeMediaItemsPostProcessor : MediaItemsPostProcessor { - override fun process(mediaItems: AsyncData>): AsyncData { - return AsyncData.Success( - GroupedMediaItems( - imageAndVideoItems = persistentListOf(), - fileItems = persistentListOf() - ) - ) - } -} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeTimelineMediaItemsFactory.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeTimelineMediaItemsFactory.kt deleted file mode 100644 index c9c95230db..0000000000 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeTimelineMediaItemsFactory.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2024 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only - * Please see LICENSE in the repository root for full details. - */ - -package io.element.android.libraries.mediaviewer.impl.gallery - -import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem -import io.element.android.tests.testutils.lambda.lambdaError -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf - -class FakeTimelineMediaItemsFactory( - private val replaceWithLambda: (List) -> Unit = { lambdaError() }, -) : TimelineMediaItemsFactory { - override val timelineItems: Flow> - get() = flowOf(emptyList().toImmutableList()) - - override suspend fun replaceWith(timelineItems: List) { - replaceWithLambda(timelineItems) - } -} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeVirtualItemFactory.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeVirtualItemFactory.kt deleted file mode 100644 index 40e2780a41..0000000000 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeVirtualItemFactory.kt +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright 2024 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only - * Please see LICENSE in the repository root for full details. - */ - -package io.element.android.libraries.mediaviewer.impl.gallery - -import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem - -class FakeVirtualItemFactory : VirtualItemFactory { - override fun create(timelineItem: MatrixTimelineItem.Virtual): MediaItem? { - return null - } -} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt index 39c88a5597..ee0a698285 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt @@ -9,10 +9,11 @@ package io.element.android.libraries.mediaviewer.impl.gallery import android.net.Uri import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter +import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.room.MatrixRoom -import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_USER_ID @@ -24,12 +25,15 @@ import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetSta import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory +import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation import io.element.android.tests.testutils.WarmUpRule 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.element.android.tests.testutils.testCoroutineDispatchers import io.mockk.mockk import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -55,7 +59,7 @@ class MediaGalleryPresenterTest { ) ) presenter.test { - skipItems(1) + skipItems(2) val initialState = awaitItem() assertThat(initialState.mode).isEqualTo(MediaGalleryMode.Images) assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) @@ -84,7 +88,7 @@ class MediaGalleryPresenterTest { ) ) presenter.test { - skipItems(1) + skipItems(2) val initialState = awaitItem() assertThat(initialState.mode).isEqualTo(MediaGalleryMode.Images) initialState.eventSink(MediaGalleryEvents.ChangeMode(MediaGalleryMode.Files)) @@ -106,7 +110,7 @@ class MediaGalleryPresenterTest { `present - bottom sheet state - own message`(canDeleteOwn = false) } - private suspend fun `present - bottom sheet state - own message`(canDeleteOwn: Boolean) { + private suspend fun TestScope.`present - bottom sheet state - own message`(canDeleteOwn: Boolean) { val presenter = createMediaGalleryPresenter( room = FakeMatrixRoom( sessionId = A_USER_ID, @@ -116,7 +120,7 @@ class MediaGalleryPresenterTest { ) ) presenter.test { - skipItems(1) + skipItems(2) val initialState = awaitItem() assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) val item = aMediaItemImage( @@ -150,7 +154,7 @@ class MediaGalleryPresenterTest { `present - bottom sheet state - other message`(canDeleteOther = false) } - private suspend fun `present - bottom sheet state - other message`(canDeleteOther: Boolean) { + private suspend fun TestScope.`present - bottom sheet state - other message`(canDeleteOther: Boolean) { val presenter = createMediaGalleryPresenter( room = FakeMatrixRoom( sessionId = A_USER_ID, @@ -160,7 +164,7 @@ class MediaGalleryPresenterTest { ) ) presenter.test { - skipItems(1) + skipItems(2) val initialState = awaitItem() assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) val item = aMediaItemImage( @@ -193,7 +197,7 @@ class MediaGalleryPresenterTest { ) ) presenter.test { - skipItems(1) + skipItems(2) val initialState = awaitItem() // Delete bottom sheet val item = aMediaItemImage() @@ -226,14 +230,14 @@ class MediaGalleryPresenterTest { navigator = navigator, ) presenter.test { - skipItems(1) + skipItems(2) val initialState = awaitItem() initialState.eventSink(MediaGalleryEvents.ViewInTimeline(AN_EVENT_ID)) onViewInTimelineClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID)) } } - private fun createMediaGalleryPresenter( + private fun TestScope.createMediaGalleryPresenter( matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(), localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(), snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), @@ -245,14 +249,21 @@ class MediaGalleryPresenterTest { return MediaGalleryPresenter( navigator = navigator, room = room, - timelineMediaItemsFactory = FakeTimelineMediaItemsFactory( - replaceWithLambda = lambdaRecorder, Unit> { _ -> }, + timelineMediaItemsFactory = TimelineMediaItemsFactory( + dispatchers = testCoroutineDispatchers(), + virtualItemFactory = VirtualItemFactory( + daySeparatorFormatter = FakeDaySeparatorFormatter(), + ), + eventItemFactory = EventItemFactory( + fileSizeFormatter = FakeFileSizeFormatter(), + fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), + ), ), localMediaFactory = localMediaFactory, mediaLoader = matrixMediaLoader, localMediaActions = localMediaActions, snackbarDispatcher = snackbarDispatcher, - mediaItemsPostProcessor = FakeMediaItemsPostProcessor(), + mediaItemsPostProcessor = MediaItemsPostProcessor(), ) } } From ec821bb9c7a45ad4659f9144c8ca258ffb4d98b5 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 10 Dec 2024 15:58:44 +0100 Subject: [PATCH 28/31] Order imports. --- .../mediaviewer/impl/gallery/MediaGalleryStateProvider.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt index 81b34020f5..481e200f04 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt @@ -14,9 +14,9 @@ import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetSta import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState import io.element.android.libraries.mediaviewer.impl.gallery.ui.aDate import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemFile -import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemVideo import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemLoadingIndicator +import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemVideo import kotlinx.collections.immutable.toImmutableList open class MediaGalleryStateProvider : PreviewParameterProvider { From 1c691d2433ae4347d7b26d205f3874ae1a24f248 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 10 Dec 2024 16:07:27 +0100 Subject: [PATCH 29/31] Add unit test on MediaItemsPostProcessor --- .../impl/gallery/MediaGalleryStateProvider.kt | 10 +- .../impl/gallery/MediaItemsPostProcessor.kt | 12 +- .../ui/MediaItemDateSeparatorProvider.kt | 6 +- .../gallery/MediaItemsPostProcessorTest.kt | 210 ++++++++++++++++++ 4 files changed, 228 insertions(+), 10 deletions(-) create mode 100644 libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessorTest.kt diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt index 481e200f04..58d566dddd 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt @@ -12,7 +12,7 @@ import io.element.android.libraries.architecture.AsyncData 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.gallery.ui.aDate +import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemDateSeparator import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemFile import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemLoadingIndicator @@ -29,9 +29,9 @@ open class MediaGalleryStateProvider : PreviewParameterProvider { - imageAndVideoItemsSubList.add(item) + imageAndVideoItemsSubList.add(0, item) } is MediaItem.File -> { - fileItemsSublist.add(item) + fileItemsSublist.add(0, item) } } } @@ -67,6 +67,14 @@ class MediaItemsPostProcessor @Inject constructor() { } } } + if (imageAndVideoItemsSubList.isNotEmpty()) { + // Should not happen, since the SDK is always adding a date separator + imageAndVideoItems.addAll(imageAndVideoItemsSubList) + } + if (fileItemsSublist.isNotEmpty()) { + // Should not happen, since the SDK is always adding a date separator + fileItems.addAll(fileItemsSublist) + } return GroupedMediaItems( imageAndVideoItems = imageAndVideoItems.toImmutableList(), fileItems = fileItems.toImmutableList(), diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemDateSeparatorProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemDateSeparatorProvider.kt index 2d7c3d50ab..32169751f0 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemDateSeparatorProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemDateSeparatorProvider.kt @@ -14,12 +14,12 @@ import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem class MediaItemDateSeparatorProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( - aDate(), - aDate(formattedDate = "A long date that should be truncated"), + aMediaItemDateSeparator(), + aMediaItemDateSeparator(formattedDate = "A long date that should be truncated"), ) } -fun aDate( +fun aMediaItemDateSeparator( id: UniqueId = UniqueId("dateId"), formattedDate: String = "October 2024", ): MediaItem.DateSeparator { diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessorTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessorTest.kt new file mode 100644 index 0000000000..75a911f1dc --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessorTest.kt @@ -0,0 +1,210 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemDateSeparator +import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemFile +import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage +import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemLoadingIndicator +import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemVideo +import kotlinx.collections.immutable.toImmutableList +import org.junit.Test + +class MediaItemsPostProcessorTest { + private val file1 = aMediaItemFile(id = UniqueId("1")) + private val file2 = aMediaItemFile(id = UniqueId("2")) + private val file3 = aMediaItemFile(id = UniqueId("3")) + private val image1 = aMediaItemImage(id = UniqueId("1")) + private val image2 = aMediaItemImage(id = UniqueId("2")) + private val image3 = aMediaItemImage(id = UniqueId("3")) + private val video1 = aMediaItemVideo(id = UniqueId("1")) + private val video2 = aMediaItemVideo(id = UniqueId("2")) + private val video3 = aMediaItemVideo(id = UniqueId("3")) + private val date1 = aMediaItemDateSeparator(id = UniqueId("1")) + private val date2 = aMediaItemDateSeparator(id = UniqueId("2")) + private val date3 = aMediaItemDateSeparator(id = UniqueId("3")) + private val loading1 = aMediaItemLoadingIndicator(id = UniqueId("1")) + + @Test + fun `process Uninitialized`() { + val sut = MediaItemsPostProcessor() + val result = sut.process(AsyncData.Uninitialized) + assertThat(result).isEqualTo(AsyncData.Uninitialized) + } + + @Test + fun `process Loading`() { + val sut = MediaItemsPostProcessor() + val result = sut.process(AsyncData.Loading()) + assertThat(result).isEqualTo(AsyncData.Loading()) + } + + @Test + fun `process Failure`() { + val sut = MediaItemsPostProcessor() + val result = sut.process(AsyncData.Failure(AN_EXCEPTION)) + assertThat(result).isEqualTo(AsyncData.Failure(AN_EXCEPTION)) + } + + @Test + fun `process Empty`() { + test( + mediaItems = listOf(), + expectedImageAndVideoItems = emptyList(), + expectedFileItems = emptyList(), + ) + } + + @Test + fun `process will reorder files`() { + test( + mediaItems = listOf( + file3, + file2, + file1, + date1, + ), + expectedImageAndVideoItems = emptyList(), + expectedFileItems = listOf( + date1, + file1, + file2, + file3, + ), + ) + } + + @Test + fun `process will reorder images`() { + test( + mediaItems = listOf( + image3, + image2, + image1, + date1, + ), + expectedImageAndVideoItems = listOf( + date1, + image1, + image2, + image3, + ), + expectedFileItems = emptyList(), + ) + } + + @Test + fun `process will split images, videos and files`() { + test( + mediaItems = listOf( + file1, + image1, + video1, + date1, + ), + expectedImageAndVideoItems = listOf( + date1, + video1, + image1, + ), + expectedFileItems = listOf( + date1, + file1, + ), + ) + } + + @Test + fun `process will skip date if there is no items`() { + test( + mediaItems = listOf( + date1, + date2, + date3, + ), + expectedImageAndVideoItems = emptyList(), + expectedFileItems = emptyList(), + ) + } + + @Test + fun `process will add the loading indicator to both list`() { + test( + mediaItems = listOf( + loading1, + ), + expectedImageAndVideoItems = listOf( + loading1, + ), + expectedFileItems = listOf( + loading1, + ), + ) + } + + @Test + fun `process will handle complex case`() { + test( + mediaItems = listOf( + file1, + image1, + video1, + date1, + file3, + date3, + video3, + video2, + date2, + loading1, + ), + expectedImageAndVideoItems = listOf( + date1, + video1, + image1, + date2, + video2, + video3, + loading1, + ), + expectedFileItems = listOf( + date1, + file1, + date3, + file3, + loading1, + ), + ) + } + + private fun test( + mediaItems: List, + expectedImageAndVideoItems: List, + expectedFileItems: List, + ) { + val sut = MediaItemsPostProcessor() + val result = sut.process(AsyncData.Success(mediaItems.toImmutableList())) + val data = result.dataOrNull()!! + + // Compare the lists to have better failure info + assertThat(data.imageAndVideoItems.toList()).isEqualTo(expectedImageAndVideoItems) + assertThat(data.fileItems.toList()).isEqualTo(expectedFileItems) + + assertThat(result).isEqualTo( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = expectedImageAndVideoItems.toImmutableList(), + fileItems = expectedFileItems.toImmutableList(), + ) + ) + ) + } +} From fc35e8e94962f68cfa69560df480ddafb37262ab Mon Sep 17 00:00:00 2001 From: ElementBot Date: Tue, 10 Dec 2024 16:09:13 +0000 Subject: [PATCH 30/31] Update screenshots --- ...es.mediaviewer.impl.gallery_MediaGalleryView_Day_10_en.png | 3 +++ ...ies.mediaviewer.impl.gallery_MediaGalleryView_Day_7_en.png | 4 ++-- ...ies.mediaviewer.impl.gallery_MediaGalleryView_Day_8_en.png | 4 ++-- ...ies.mediaviewer.impl.gallery_MediaGalleryView_Day_9_en.png | 3 +++ ....mediaviewer.impl.gallery_MediaGalleryView_Night_10_en.png | 3 +++ ...s.mediaviewer.impl.gallery_MediaGalleryView_Night_7_en.png | 4 ++-- ...s.mediaviewer.impl.gallery_MediaGalleryView_Night_8_en.png | 4 ++-- ...s.mediaviewer.impl.gallery_MediaGalleryView_Night_9_en.png | 3 +++ ...ibraries.mediaviewer.impl.viewer_MediaViewerView_11_en.png | 3 +++ ...ibraries.mediaviewer.impl.viewer_MediaViewerView_12_en.png | 3 +++ 10 files changed, 26 insertions(+), 8 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_10_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_9_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_10_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_9_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_11_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_12_en.png diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_10_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_10_en.png new file mode 100644 index 0000000000..ef0ab7b7ee --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_10_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:925df53e0666d800b0f047f45abda50f1744ef1571594be50efd6008ed988b72 +size 14549 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_7_en.png index 18144ddccb..7a192f89f4 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_7_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1b7055ce0a8214e7c445c2d1b7d30ad5ffc63617c9f9f837661dfbd057198681 -size 26092 +oid sha256:ce8fec32ec1a902edb12d9580ca73d0cc5a9838f91d31dbbf8329326b9fa1a68 +size 39676 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_8_en.png index 5b1aa7f25a..03b462a778 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:789e94ac051194e1a43d2b211301537a17596fd30b7a670a7d39107dcfe6471d -size 40514 +oid sha256:983e505a211c92ae92e091f9ba7cc43a655d5f3ce6d6bcf70971d43984507326 +size 36153 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_9_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_9_en.png new file mode 100644 index 0000000000..90c8032c3e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_9_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fff7687206e1b1c03ecc4da231e8d030ec730573070550f1d6a7c355ba0c90d2 +size 14525 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_10_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_10_en.png new file mode 100644 index 0000000000..2ffaf9289e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_10_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d3fe3f3e7668a3d529132172366622db970391dcc8718f87a7fc90ec67b93ed1 +size 13992 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_7_en.png index aca9e44958..41f7c7f5fd 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_7_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4f07b50b154c426638c38897fca296f5d52fb0054ad459eadf8fbe344c9b0526 -size 25475 +oid sha256:946183ccbaf14471579f3b48861715d04ed45d6352d36126a419de7e6f362bfd +size 37632 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_8_en.png index 5e1abf250a..85ef965bae 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:44a96a7ad9425d869b06d3339b8356fbb9d213f1ce1a987b57ddc8117885daea -size 38480 +oid sha256:4e7a43195bd617ee952ea084e6b17a7e08e8b3634e8f3ce7df6e98f067ab08dc +size 34296 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_9_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_9_en.png new file mode 100644 index 0000000000..506cb4f837 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_9_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bd4903a8a383c5fe3c8c5bfeaf26ab16d3c785fccaab5b66de8b31f3380e9272 +size 14104 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_11_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_11_en.png new file mode 100644 index 0000000000..99618a6ea0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_11_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8a6916d2441316cb3ef55ee4c6a3b3ba9134d246d72c27aa3871408a6b9e59fc +size 30716 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_12_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_12_en.png new file mode 100644 index 0000000000..70f2f64c13 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_12_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:514f67acd325e01d010466786eb85e7db8071c36e2f21454be8f30c4a6a57425 +size 32356 From 0113a52c666f0267f7c0b47aad92f494f7460c8b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 11 Dec 2024 09:55:04 +0100 Subject: [PATCH 31/31] Fix test. I'll iterate on the various date format in a separate PR. --- .../mediaviewer/impl/gallery/EventItemFactory.kt | 8 +++----- .../impl/gallery/DefaultEventItemFactoryTest.kt | 15 +++++++++------ .../impl/gallery/MediaGalleryPresenterTest.kt | 3 +++ 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt index 15848d85a1..6b96500149 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt @@ -8,6 +8,7 @@ package io.element.android.libraries.mediaviewer.impl.gallery import io.element.android.libraries.androidutils.filesize.FileSizeFormatter +import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter import io.element.android.libraries.dateformatter.api.toHumanReadableDuration import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType @@ -39,21 +40,18 @@ import io.element.android.libraries.matrix.api.timeline.item.event.getDisambigua import io.element.android.libraries.mediaviewer.api.MediaInfo import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor import timber.log.Timber -import java.text.DateFormat -import java.util.Date import javax.inject.Inject class EventItemFactory @Inject constructor( private val fileSizeFormatter: FileSizeFormatter, private val fileExtensionExtractor: FileExtensionExtractor, + private val lastMessageTimestampFormatter: LastMessageTimestampFormatter, ) { - private val timeFormatter = DateFormat.getDateInstance() - fun create( currentTimelineItem: MatrixTimelineItem.Event, ): MediaItem.Event? { val event = currentTimelineItem.event - val sentTime = timeFormatter.format(Date(currentTimelineItem.event.timestamp)) + val sentTime = lastMessageTimestampFormatter.format(currentTimelineItem.event.timestamp) return when (val content = event.content) { CallNotifyContent, is FailedToParseMessageLikeContent, diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/DefaultEventItemFactoryTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/DefaultEventItemFactoryTest.kt index 487c30dbbf..3dde8176b4 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/DefaultEventItemFactoryTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/DefaultEventItemFactoryTest.kt @@ -10,6 +10,8 @@ package io.element.android.libraries.mediaviewer.impl.gallery import com.google.common.truth.Truth.assertThat import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.dateformatter.test.A_FORMATTED_DATE +import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter import io.element.android.libraries.matrix.api.media.AudioDetails import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.FileInfo @@ -160,7 +162,7 @@ class DefaultEventItemFactoryTest { senderId = A_USER_ID, senderName = "alice", senderAvatar = null, - dateSent = "1 Jan 1970", + dateSent = A_FORMATTED_DATE, ), mediaSource = MediaSource(""), ) @@ -207,7 +209,7 @@ class DefaultEventItemFactoryTest { senderId = A_USER_ID, senderName = "alice", senderAvatar = null, - dateSent = "1 Jan 1970", + dateSent = A_FORMATTED_DATE, ), mediaSource = MediaSource(""), thumbnailSource = null, @@ -251,7 +253,7 @@ class DefaultEventItemFactoryTest { senderId = A_USER_ID, senderName = "alice", senderAvatar = null, - dateSent = "1 Jan 1970", + dateSent = A_FORMATTED_DATE, ), mediaSource = MediaSource(""), ) @@ -299,7 +301,7 @@ class DefaultEventItemFactoryTest { senderId = A_USER_ID, senderName = "alice", senderAvatar = null, - dateSent = "1 Jan 1970", + dateSent = A_FORMATTED_DATE, ), mediaSource = MediaSource(""), thumbnailSource = null, @@ -348,7 +350,7 @@ class DefaultEventItemFactoryTest { senderId = A_USER_ID, senderName = "alice", senderAvatar = null, - dateSent = "1 Jan 1970", + dateSent = A_FORMATTED_DATE, ), mediaSource = MediaSource(""), ) @@ -395,7 +397,7 @@ class DefaultEventItemFactoryTest { senderId = A_USER_ID, senderName = "alice", senderAvatar = null, - dateSent = "1 Jan 1970", + dateSent = A_FORMATTED_DATE, ), mediaSource = MediaSource(""), thumbnailSource = null, @@ -407,4 +409,5 @@ class DefaultEventItemFactoryTest { private fun createEventItemFactory() = EventItemFactory( fileSizeFormatter = FakeFileSizeFormatter(), fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), + lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(A_FORMATTED_DATE), ) diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt index ee0a698285..4aeada8701 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt @@ -10,7 +10,9 @@ package io.element.android.libraries.mediaviewer.impl.gallery import android.net.Uri import com.google.common.truth.Truth.assertThat import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter +import io.element.android.libraries.dateformatter.test.A_FORMATTED_DATE import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter +import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.room.MatrixRoom @@ -257,6 +259,7 @@ class MediaGalleryPresenterTest { eventItemFactory = EventItemFactory( fileSizeFormatter = FakeFileSizeFormatter(), fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), + lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(A_FORMATTED_DATE), ), ), localMediaFactory = localMediaFactory,