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" : [