Media Gallery
This commit is contained in:
parent
c1c1264e9a
commit
3e1b1c29d1
69 changed files with 3822 additions and 56 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -57,4 +57,6 @@ enum class AvatarSize(val dp: Dp) {
|
|||
|
||||
KnockRequestItem(52.dp),
|
||||
KnockRequestBanner(32.dp),
|
||||
|
||||
MediaSender(32.dp),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@ interface Timeline : AutoCloseable {
|
|||
enum class Mode {
|
||||
LIVE,
|
||||
FOCUSED_ON_EVENT,
|
||||
PINNED_EVENTS
|
||||
PINNED_EVENTS,
|
||||
MEDIA,
|
||||
}
|
||||
|
||||
val membershipChangeEventReceived: Flow<Unit>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
@ -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 = {}
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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(""),
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
16
libraries/mediaviewer/impl/src/main/res/values/localazy.xml
Normal file
16
libraries/mediaviewer/impl/src/main/res/values/localazy.xml
Normal 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 won’t 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>
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -38,7 +38,9 @@ class AndroidLocalMediaFactoryTest {
|
|||
mimeType = MimeTypes.Jpeg,
|
||||
formattedFileSize = "4MB",
|
||||
fileExtension = "jpg",
|
||||
senderId = null,
|
||||
senderName = A_USER_NAME,
|
||||
senderAvatar = null,
|
||||
dateSent = "12:34"
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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" : [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue