Media Gallery

This commit is contained in:
Benoit Marty 2024-12-09 16:45:46 +01:00
parent c1c1264e9a
commit 3e1b1c29d1
69 changed files with 3822 additions and 56 deletions

View file

@ -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,

View file

@ -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<RoomDetailsFlowNode.NavTarget>(
backstack = BackStack(
initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().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<RoomDetailsEntryPoint.Callback>().forEach {
it.onPermalinkClick(permalinkData, pushToBackstack = false)
}
}
}
mediaGalleryEntryPoint.nodeBuilder(this, buildContext)
.callback(callback)
.build()
}
is NavTarget.AdminSettings -> {
createNode<RolesAndPermissionsFlowNode>(buildContext)

View file

@ -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,

View file

@ -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,

View file

@ -40,6 +40,7 @@ data class RoomDetailsState(
val isPublic: Boolean,
val heroes: ImmutableList<MatrixUser>,
val canShowPinnedMessages: Boolean,
val canShowMediaGallery: Boolean,
val pinnedMessagesCount: Int?,
val canShowKnockRequests: Boolean,
val knockRequestsCount: Int?,

View file

@ -101,6 +101,7 @@ fun aRoomDetailsState(
isPublic: Boolean = true,
heroes: List<MatrixUser> = 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,

View file

@ -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 = {},

View file

@ -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(

View file

@ -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"
}

View file

@ -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()

View file

@ -57,4 +57,6 @@ enum class AvatarSize(val dp: Dp) {
KnockRequestItem(52.dp),
KnockRequestBanner(32.dp),
MediaSender(32.dp),
}

View file

@ -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,
),
}

View file

@ -107,6 +107,11 @@ interface MatrixRoom : Closeable {
*/
suspend fun pinnedEventsTimeline(): Result<Timeline>
/**
* Create a new timeline for the media events of the room.
*/
suspend fun mediaTimeline(): Result<Timeline>
fun destroy()
suspend fun subscribeToSync()

View file

@ -42,7 +42,8 @@ interface Timeline : AutoCloseable {
enum class Mode {
LIVE,
FOCUSED_ON_EVENT,
PINNED_EVENTS
PINNED_EVENTS,
MEDIA,
}
val membershipChangeEventReceived: Flow<Unit>

View file

@ -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<Timeline> {
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()

View file

@ -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<Boolean> = 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) }
}
}
}

View file

@ -133,6 +133,7 @@ class FakeMatrixRoom(
private val getMembersResult: (Int) -> Result<List<RoomMember>> = { lambdaError() },
private val timelineFocusedOnEventResult: (EventId) -> Result<Timeline> = { lambdaError() },
private val pinnedEventsTimelineResult: () -> Result<Timeline> = { lambdaError() },
private val mediaTimelineResult: () -> Result<Timeline> = { lambdaError() },
private val setSendQueueEnabledLambda: (Boolean) -> Unit = { _: Boolean -> },
private val saveComposerDraftLambda: (ComposerDraft) -> Result<Unit> = { _: ComposerDraft -> Result.success(Unit) },
private val loadComposerDraftLambda: () -> Result<ComposerDraft?> = { Result.success<ComposerDraft?>(null) },
@ -203,6 +204,10 @@ class FakeMatrixRoom(
pinnedEventsTimelineResult()
}
override suspend fun mediaTimeline(): Result<Timeline> = simulateLongTask {
mediaTimelineResult()
}
override suspend fun subscribeToSync() {
subscribeToSyncLambda()
}

View file

@ -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)
}
}

View file

@ -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,
)

View file

@ -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

View file

@ -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)

View file

@ -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<Plugin>()
return object : MediaGalleryEntryPoint.NodeBuilder {
override fun callback(callback: MediaGalleryEntryPoint.Callback): MediaGalleryEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun build(): Node {
return parentNode.createNode<MediaGalleryRootNode>(buildContext, plugins)
}
}
}
}

View file

@ -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,
)

View file

@ -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
}

View file

@ -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 = {},
)
}

View file

@ -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 = {},
)
}

View file

@ -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,
)
}
}
}
}
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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<Plugin>,
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<Callback>().forEach {
it.onDone()
}
}
override fun onViewInTimelineClick(eventId: EventId) {
plugins<Callback>().forEach {
it.onViewInTimeline(eventId)
}
}
private fun onItemClick(item: MediaItem.Event) {
plugins<Callback>().forEach {
it.onItemClick(item)
}
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
MediaGalleryView(
state = state,
onBackClick = ::onDone,
onItemClick = ::onItemClick,
modifier = modifier,
)
}
}

View file

@ -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<MediaGalleryState> {
@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>(MediaBottomSheetState.Hidden) }
var mediaItems by remember {
mutableStateOf<AsyncData<ImmutableList<MediaItem>>>(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<ImmutableList<MediaItem>>) -> 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<LocalMedia> {
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
}
}
}

View file

@ -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<ImmutableList<MediaItem>>,
val fileItems: AsyncData<ImmutableList<MediaItem>>,
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),
}

View file

@ -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<MediaGalleryState> {
override val values: Sequence<MediaGalleryState>
get() = sequenceOf(
aMediaGalleryState(),
aMediaGalleryState(imageItems = AsyncData.Loading()),
aMediaGalleryState(imageItems = AsyncData.Success(emptyList<MediaItem.Image>().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<MediaItem.File>().toPersistentList())),
aMediaGalleryState(mode = MediaGalleryMode.Files, fileItems = AsyncData.Success(emptyList<MediaItem.File>().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<ImmutableList<MediaItem>> = AsyncData.Uninitialized,
fileItems: AsyncData<ImmutableList<MediaItem>> = AsyncData.Uninitialized,
) = MediaGalleryState(
roomName = roomName,
mode = mode,
imageItems = imageItems,
fileItems = fileItems,
mediaBottomSheetState = MediaBottomSheetState.Hidden,
snackbarMessage = null,
eventSink = {}
)

View file

@ -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<AsyncData<Timeline>> =
MutableStateFlow(AsyncData.Uninitialized)
override fun activeTimelineFlow(): StateFlow<Timeline?> {
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
}
}
}

View file

@ -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<ImmutableList<MediaItem>>,
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<ImmutableList<MediaItem>>,
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<MediaItem>,
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<MediaItem>,
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 = {},
)
}

View file

@ -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
}
}

View file

@ -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<ImmutableList<MediaItem>>,
predicate: (MediaItem.Event) -> Boolean,
): AsyncData<ImmutableList<MediaItem>>
}
@ContributesBinding(AppScope::class)
class DefaultMediaItemsPostProcessor @Inject constructor(
) : MediaItemsPostProcessor {
override fun process(
mediaItems: AsyncData<ImmutableList<MediaItem>>,
predicate: (MediaItem.Event) -> Boolean,
): AsyncData<ImmutableList<MediaItem>> {
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<MediaItem>,
predicate: (MediaItem.Event) -> Boolean,
) = buildList {
val eventList = mutableListOf<MediaItem.Event>()
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()
}

View file

@ -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<ImmutableList<MediaItem>>
suspend fun replaceWith(timelineItems: List<MatrixTimelineItem>)
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<ImmutableList<MediaItem>>(replay = 1)
private val lock = Mutex()
private val diffCache = MutableListDiffCache<MediaItem>()
private val diffCacheUpdater = DiffCacheUpdater<MatrixTimelineItem, MediaItem>(
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<ImmutableList<MediaItem>> = _timelineItems.distinctUntilChanged()
override suspend fun replaceWith(
timelineItems: List<MatrixTimelineItem>,
) = 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<MatrixTimelineItem>,
) {
val newTimelineItemStates = ArrayList<MediaItem>()
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<MatrixTimelineItem>,
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
}
}

View file

@ -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
}
}
}

View file

@ -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<MediaItem> {
private val delegate = DefaultDiffCacheInvalidator<MediaItem>()
override fun onChanged(position: Int, count: Int, cache: MutableDiffCache<MediaItem>) {
delegate.onChanged(position, count, cache)
}
override fun onMoved(fromPosition: Int, toPosition: Int, cache: MutableDiffCache<MediaItem>) {
delegate.onMoved(fromPosition, toPosition, cache)
}
override fun onInserted(position: Int, count: Int, cache: MutableDiffCache<MediaItem>) {
cache.invalidateAround(position)
delegate.onInserted(position, count, cache)
}
override fun onRemoved(position: Int, count: Int, cache: MutableDiffCache<MediaItem>) {
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)
}
}

View file

@ -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<Plugin>,
private val mediaViewerEntryPoint: MediaViewerEntryPoint
) : BaseFlowNode<MediaGalleryRootNode.NavTarget>(
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<MediaGalleryEntryPoint.Callback>().forEach {
it.onDone()
}
}
private fun onViewInTimeline(eventId: EventId) {
plugins<MediaGalleryEntryPoint.Callback>().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<MediaGalleryNode>(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)
}
}

View file

@ -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)
}

View file

@ -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 = {},
)
}

View file

@ -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 = {},
)
}

View file

@ -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<MediaItem.DateSeparator> {
override val values: Sequence<MediaItem.DateSeparator>
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,
)
}

View file

@ -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<MediaItem.File> {
override val values: Sequence<MediaItem.File>
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(""),
)
}

View file

@ -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,
)
}

View file

@ -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<MediaItem.Video> {
override val values: Sequence<MediaItem.Video>
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,
)
}

View file

@ -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 = {},
)
}

View file

@ -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,
)
)

View file

@ -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
}

View file

@ -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()
}

View file

@ -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<Plugin>,
presenterFactory: MediaViewerPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
) : Node(buildContext, plugins = plugins),
MediaViewerNavigator {
private val inputs = inputs<MediaViewerEntryPoint.Params>()
private fun onDone() {
@ -35,7 +37,20 @@ open class MediaViewerNode @AssistedInject constructor(
}
}
private val presenter = presenterFactory.create(inputs)
override fun onViewInTimelineClick(eventId: EventId) {
plugins<MediaViewerEntryPoint.Callback>().forEach {
it.onViewInTimeline(eventId)
}
}
override fun onItemDeleted() {
onDone()
}
private val presenter = presenterFactory.create(
inputs = inputs,
navigator = this,
)
@Composable
override fun View(modifier: Modifier) {

View file

@ -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<MediaViewerState> {
@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<LocalMedia>) = launch {
if (localMedia is AsyncData.Success) {
localMediaActions.share(localMedia.data)

View file

@ -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<LocalMedia>,
val snackbarMessage: SnackbarMessage?,
val canShowInfo: Boolean,
val canDownload: Boolean,
val canShare: Boolean,
val canDelete: Boolean,
val eventSink: (MediaViewerEvents) -> Unit,
)

View file

@ -30,10 +30,10 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
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<MediaViewerState>
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<MediaViewerState>
fun aMediaViewerState(
downloadedMedia: AsyncData<LocalMedia> = 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,
)

View file

@ -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>(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,
)
}
}
}
)
}

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_media_browser_delete_confirmation_subtitle">"This file will be removed from the room and members wont have access to it."</string>
<string name="screen_media_browser_delete_confirmation_title">"Delete file?"</string>
<string name="screen_media_browser_empty_state_subtitle">"Images and videos uploaded to this room will be shown here."</string>
<string name="screen_media_browser_empty_state_title">"No media uploaded yet"</string>
<string name="screen_media_browser_list_loading_files">"Loading files…"</string>
<string name="screen_media_browser_list_loading_media">"Loading media…"</string>
<string name="screen_media_browser_list_mode_files">"Files"</string>
<string name="screen_media_browser_list_mode_media">"Media"</string>
<string name="screen_media_browser_title">"Media and files"</string>
<string name="screen_media_details_file_format">"File format"</string>
<string name="screen_media_details_filename">"File name"</string>
<string name="screen_media_details_uploaded_by">"Uploaded by"</string>
<string name="screen_media_details_uploaded_on">"Uploaded on"</string>
</resources>

View file

@ -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
}
}

View file

@ -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)
}
}

View file

@ -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<ImmutableList<MediaItem>>, predicate: (MediaItem.Event) -> Boolean): AsyncData<ImmutableList<MediaItem>> {
return mediaItems
}
}

View file

@ -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<MatrixTimelineItem>) -> Unit = { lambdaError() },
private val onCanPaginateLambda: () -> Unit = { lambdaError() }
) : TimelineMediaItemsFactory {
override val timelineItems: Flow<ImmutableList<MediaItem>>
get() = flowOf(emptyList<MediaItem>().toImmutableList())
override suspend fun replaceWith(timelineItems: List<MatrixTimelineItem>) {
replaceWithLambda(timelineItems)
}
override suspend fun onCanPaginate() {
onCanPaginateLambda()
}
}

View file

@ -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
}
}

View file

@ -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<EventId, Unit> { }
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<EventId, Unit> { }
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<EventId, Unit> { }
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<List<MatrixTimelineItem>, Unit> { _ -> },
onCanPaginateLambda = lambdaRecorder<Unit> { },
),
localMediaFactory = localMediaFactory,
mediaLoader = matrixMediaLoader,
localMediaActions = localMediaActions,
snackbarDispatcher = snackbarDispatcher,
mediaItemsPostProcessor = FakeMediaItemsPostProcessor(),
)
}
}

View file

@ -38,7 +38,9 @@ class AndroidLocalMediaFactoryTest {
mimeType = MimeTypes.Jpeg,
formattedFileSize = "4MB",
fileExtension = "jpg",
senderId = null,
senderName = A_USER_NAME,
senderAvatar = null,
dateSent = "12:34"
)
)

View file

@ -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()
}
}

View file

@ -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<EventOrTransactionId, String?, Result<Unit>> { _, _ ->
Result.success(Unit)
}
val timeline = FakeTimeline().apply {
this.redactEventLambda = redactEventLambda
}
val onItemDeletedLambda = lambdaRecorder<Unit> { }
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<EventId, Unit> { }
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,
)
}
}

View file

@ -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)

View file

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