diff --git a/features/knockrequests/impl/src/main/res/values-cs/translations.xml b/features/knockrequests/impl/src/main/res/values-cs/translations.xml
index b02fdf8595..b8c4f6f125 100644
--- a/features/knockrequests/impl/src/main/res/values-cs/translations.xml
+++ b/features/knockrequests/impl/src/main/res/values-cs/translations.xml
@@ -14,4 +14,13 @@
"Když někdo požádá o vstup do místnosti, uvidíte jeho žádost zde."
"Žádná čekající žádost o vstup"
"Žádosti o vstup"
+
+ - "%1$s +%2$d další chce vstoupit do této místnosti"
+ - "%1$s +%2$d další chtějí vstoupit do této místnosti"
+ - "%1$s +%2$d dalších chce vstoupit do této místnosti"
+
+ "Zobrazit vše"
+ "Přijmout"
+ "%1$s chce vstoupit do této místnosti"
+ "Zobrazit"
diff --git a/features/knockrequests/impl/src/main/res/values-de/translations.xml b/features/knockrequests/impl/src/main/res/values-de/translations.xml
index 693b1c8882..70e43ba076 100644
--- a/features/knockrequests/impl/src/main/res/values-de/translations.xml
+++ b/features/knockrequests/impl/src/main/res/values-de/translations.xml
@@ -14,4 +14,12 @@
"Falls jemand um Aufnahme in den Raum bittet, können Sie dessen Anfrage hier sehen."
"Keine ausstehende Beitrittsanfrage"
"Beitrittsanfragen"
+
+ - "%1$s+ %2$d andere wollen diesem Chatroom beitreten"
+ - "%1$s+ %2$d andere wollen diesem Chatroom beitreten"
+
+ "Alles ansehen"
+ "Akzeptieren"
+ "%1$s möchte diesem Chatroom beitreten"
+ "Ansicht"
diff --git a/features/knockrequests/impl/src/main/res/values-el/translations.xml b/features/knockrequests/impl/src/main/res/values-el/translations.xml
index 794312dd93..a59b6757db 100644
--- a/features/knockrequests/impl/src/main/res/values-el/translations.xml
+++ b/features/knockrequests/impl/src/main/res/values-el/translations.xml
@@ -14,4 +14,12 @@
"Όταν κάποιος θα ζητήσει να συμμετάσχει στο δωμάτιο, θα μπορείς να δεις το αίτημά του εδώ."
"Δεν υπάρχει εκκρεμές αίτημα συμμετοχής"
"Αιτήματα συμμετοχής"
+
+ - "Οι χρήστες %1$s +%2$d ακόμη θέλουν να συμμετάσχουν σε αυτό το δωμάτιο"
+ - "Οι χρήστες %1$s +%2$d ακόμη θέλουν να συμμετάσχουν σε αυτό το δωμάτιο"
+
+ "Προβολή όλων"
+ "Αποδοχή"
+ "Ο χρήστης %1$s θέλει να μπει σε αυτό το δωμάτιο"
+ "Προβολή"
diff --git a/features/knockrequests/impl/src/main/res/values-et/translations.xml b/features/knockrequests/impl/src/main/res/values-et/translations.xml
index bd647dcb09..79d9c56964 100644
--- a/features/knockrequests/impl/src/main/res/values-et/translations.xml
+++ b/features/knockrequests/impl/src/main/res/values-et/translations.xml
@@ -14,4 +14,12 @@
"Kui keegi soovib jututoaga liituda, siis need päringud on kuvatud siin."
"Pole ühtegi liitumispalvet"
"Liitumispalved"
+
+ - "%1$s + veel %2$d kasutaja soovivad selle jututoaga liituda"
+ - "%1$s + veel %2$d kasutajat soovivad selle jututoaga liituda"
+
+ "Vaata kõiki"
+ "Nõustu"
+ "%1$s soovib selle jututoaga liituda"
+ "Vaata"
diff --git a/features/knockrequests/impl/src/main/res/values-fr/translations.xml b/features/knockrequests/impl/src/main/res/values-fr/translations.xml
index 6632df1dd3..39bc75f5c1 100644
--- a/features/knockrequests/impl/src/main/res/values-fr/translations.xml
+++ b/features/knockrequests/impl/src/main/res/values-fr/translations.xml
@@ -14,4 +14,12 @@
"Lorsque quelqu’un demandera à rejoindre le salon, vous pourrez voir sa demande ici."
"Personne ne demande à rejoindre le salon"
"Demandes en attente"
+
+ - "%1$s et %2$d autre personne souhaitent rejoindre ce salon"
+ - "%1$s et %2$d autres personnes souhaitent rejoindre ce salon"
+
+ "Tout afficher"
+ "Accepter"
+ "%1$s souhaite rejoindre ce salon"
+ "Voir"
diff --git a/features/knockrequests/impl/src/main/res/values-hu/translations.xml b/features/knockrequests/impl/src/main/res/values-hu/translations.xml
index 2093d0103d..ba1aa620c6 100644
--- a/features/knockrequests/impl/src/main/res/values-hu/translations.xml
+++ b/features/knockrequests/impl/src/main/res/values-hu/translations.xml
@@ -14,4 +14,12 @@
"Ha valaki csatlakozni kíván a szobához, itt láthatja a kérését."
"Nincs függőben lévő csatlakozási kérelem"
"Csatlakozási kérelmek"
+
+ - "%1$s és még %2$d felhasználó szeretne csatlakozni ehhez a szobához"
+ - "%1$s és még %2$d felhasználó szeretne csatlakozni ehhez a szobához"
+
+ "Összes megtekintése"
+ "Elfogadás"
+ "%1$s szeretne csatlakozni ehhez a szobához"
+ "Megtekintés"
diff --git a/features/knockrequests/impl/src/main/res/values-it/translations.xml b/features/knockrequests/impl/src/main/res/values-it/translations.xml
index a3e05bb8ef..ebdba8074a 100644
--- a/features/knockrequests/impl/src/main/res/values-it/translations.xml
+++ b/features/knockrequests/impl/src/main/res/values-it/translations.xml
@@ -14,4 +14,12 @@
"Quando qualcuno ti chiederà di entrare nella stanza, potrai vedere la sua richiesta qui."
"Nessuna richiesta di accesso in sospeso"
"Richieste di accesso"
+
+ - "%1$s +%2$d vogliono entrare in questa stanza"
+ - "%1$s +%2$d vogliono entrare in questa stanza"
+
+ "Visualizza tutte"
+ "Accetta"
+ "%1$s vuole entrare in questa stanza"
+ "Visualizza"
diff --git a/features/knockrequests/impl/src/main/res/values-ru/translations.xml b/features/knockrequests/impl/src/main/res/values-ru/translations.xml
index 2542195139..1023af2d28 100644
--- a/features/knockrequests/impl/src/main/res/values-ru/translations.xml
+++ b/features/knockrequests/impl/src/main/res/values-ru/translations.xml
@@ -14,4 +14,13 @@
"Вы сможете увидеть запрос, когда кто-то попросит присоединиться к комнате."
"Нет ожидающих запросов на присоединение"
"Запросы на присоединение"
+
+ - "%1$s +%2$d хочет присоединиться к этой комнате"
+ - "%1$s +%2$d хотят присоединиться к этой комнате"
+ - "%1$s +%2$d хотят присоединиться к этой комнате"
+
+ "Показать все"
+ "Принять"
+ "%1$s хочет присоединиться к этой комнате"
+ "Просмотр"
diff --git a/features/knockrequests/impl/src/main/res/values-sk/translations.xml b/features/knockrequests/impl/src/main/res/values-sk/translations.xml
index 4dff32780a..1504ef8631 100644
--- a/features/knockrequests/impl/src/main/res/values-sk/translations.xml
+++ b/features/knockrequests/impl/src/main/res/values-sk/translations.xml
@@ -5,4 +5,13 @@
"Keď niekto požiada, aby sa pripojil k miestnosti, jeho žiadosť si môžete pozrieť tu."
"Žiadna čakajúca žiadosť o pripojenie"
"Žiadosti o pripojenie"
+
+ - "%1$s +%2$d ďalší chcú vstúpiť do tejto miestnosti"
+ - "%1$s +%2$d ďalší chcú vstúpiť do tejto miestnosti"
+ - "%1$s +%2$d ďalších chce vstúpiť do tejto miestnosti"
+
+ "Zobraziť všetko"
+ "Prijať"
+ "%1$s chce vstúpiť do tejto miestnosti"
+ "Zobraziť"
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
index 27b219fc1c..3b5fb67540 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
@@ -117,6 +117,7 @@ class MessagesFlowNode @AssistedInject constructor(
@Parcelize
data class MediaViewer(
+ val eventId: EventId?,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
@@ -241,9 +242,11 @@ class MessagesFlowNode @AssistedInject constructor(
}
is NavTarget.MediaViewer -> {
val params = MediaViewerEntryPoint.Params(
+ eventId = navTarget.eventId,
mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource,
+ canShowInfo = true,
canDownload = true,
canShare = true,
)
@@ -251,6 +254,10 @@ class MessagesFlowNode @AssistedInject constructor(
override fun onDone() {
overlay.hide()
}
+
+ override fun onViewInTimeline(eventId: EventId) {
+ viewInTimeline(eventId)
+ }
}
mediaViewerEntryPoint.nodeBuilder(this, buildContext)
.params(params)
@@ -311,11 +318,7 @@ class MessagesFlowNode @AssistedInject constructor(
}
override fun onViewInTimelineClick(eventId: EventId) {
- val permalinkData = PermalinkData.RoomLink(
- roomIdOrAlias = room.roomId.toRoomIdOrAlias(),
- eventId = eventId,
- )
- callbacks.forEach { it.onPermalinkClick(permalinkData, pushToBackstack = false) }
+ viewInTimeline(eventId)
}
override fun onRoomPermalinkClick(data: PermalinkData.RoomLink) {
@@ -341,6 +344,14 @@ class MessagesFlowNode @AssistedInject constructor(
}
}
+ private fun viewInTimeline(eventId: EventId) {
+ val permalinkData = PermalinkData.RoomLink(
+ roomIdOrAlias = room.roomId.toRoomIdOrAlias(),
+ eventId = eventId,
+ )
+ callbacks.forEach { it.onPermalinkClick(permalinkData, pushToBackstack = false) }
+ }
+
private fun processEventClick(event: TimelineItem.Event): Boolean {
val navTarget = when (event.content) {
is TimelineItemImageContent -> {
@@ -415,13 +426,16 @@ class MessagesFlowNode @AssistedInject constructor(
thumbnailSource: MediaSource?,
): NavTarget {
return NavTarget.MediaViewer(
+ eventId = event.eventId,
mediaInfo = MediaInfo(
filename = content.filename,
caption = content.caption,
mimeType = content.mimeType,
formattedFileSize = content.formattedFileSize,
fileExtension = content.fileExtension,
+ senderId = event.senderId,
senderName = event.safeSenderName,
+ senderAvatar = event.senderAvatar.url,
dateSent = event.sentTime,
),
mediaSource = mediaSource,
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt
index eb13a0ba5b..f89953263c 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt
@@ -15,6 +15,7 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
+import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
@@ -39,10 +40,13 @@ import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.overlay.operation.hide
import io.element.android.libraries.architecture.overlay.operation.show
import io.element.android.libraries.di.RoomScope
+import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
@@ -59,6 +63,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
private val messagesEntryPoint: MessagesEntryPoint,
private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint,
private val mediaViewerEntryPoint: MediaViewerEntryPoint,
+ private val mediaGalleryEntryPoint: MediaGalleryEntryPoint,
) : BaseFlowNode(
backstack = BackStack(
initialElement = plugins.filterIsInstance().first().initialElement.toNavTarget(),
@@ -98,6 +103,9 @@ class RoomDetailsFlowNode @AssistedInject constructor(
@Parcelize
data object PollHistory : NavTarget
+ @Parcelize
+ data object MediaGallery : NavTarget
+
@Parcelize
data object AdminSettings : NavTarget
@@ -136,6 +144,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
backstack.push(NavTarget.PollHistory)
}
+ override fun openMediaGallery() {
+ backstack.push(NavTarget.MediaGallery)
+ }
+
override fun openAdminSettings() {
backstack.push(NavTarget.AdminSettings)
}
@@ -213,6 +225,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
override fun onDone() {
overlay.hide()
}
+
+ override fun onViewInTimeline(eventId: EventId) {
+ // Cannot happen
+ }
}
mediaViewerEntryPoint.nodeBuilder(this, buildContext)
.avatar(
@@ -222,10 +238,29 @@ class RoomDetailsFlowNode @AssistedInject constructor(
.callback(callback)
.build()
}
-
is NavTarget.PollHistory -> {
pollHistoryEntryPoint.createNode(this, buildContext)
}
+ is NavTarget.MediaGallery -> {
+ val callback = object : MediaGalleryEntryPoint.Callback {
+ override fun onBackClick() {
+ backstack.pop()
+ }
+
+ override fun onViewInTimeline(eventId: EventId) {
+ val permalinkData = PermalinkData.RoomLink(
+ roomIdOrAlias = room.roomId.toRoomIdOrAlias(),
+ eventId = eventId,
+ )
+ plugins().forEach {
+ it.onPermalinkClick(permalinkData, pushToBackstack = false)
+ }
+ }
+ }
+ mediaGalleryEntryPoint.nodeBuilder(this, buildContext)
+ .callback(callback)
+ .build()
+ }
is NavTarget.AdminSettings -> {
createNode(buildContext)
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt
index a56a028808..3b3d11fbd9 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt
@@ -45,6 +45,7 @@ class RoomDetailsNode @AssistedInject constructor(
fun openRoomNotificationSettings()
fun openAvatarPreview(name: String, url: String)
fun openPollHistory()
+ fun openMediaGallery()
fun openAdminSettings()
fun openPinnedMessagesList()
fun openKnockRequestsList()
@@ -77,6 +78,10 @@ class RoomDetailsNode @AssistedInject constructor(
callbacks.forEach { it.openPollHistory() }
}
+ private fun openMediaGallery() {
+ callbacks.forEach { it.openMediaGallery() }
+ }
+
private fun onJoinCall() {
callbacks.forEach { it.onJoinCall() }
}
@@ -143,6 +148,7 @@ class RoomDetailsNode @AssistedInject constructor(
invitePeople = ::invitePeople,
openAvatarPreview = ::openAvatarPreview,
openPollHistory = ::openPollHistory,
+ openMediaGallery = ::openMediaGallery,
openAdminSettings = this::openAdminSettings,
onJoinCallClick = ::onJoinCall,
onPinnedMessagesClick = ::openPinnedMessages,
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
index 46588ae5fe..d55f4e4f7d 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
@@ -17,6 +17,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
@@ -79,6 +80,10 @@ class RoomDetailsPresenter @Inject constructor(
val isPublic by remember { derivedStateOf { roomInfo?.isPublic.orFalse() } }
val canShowPinnedMessages = isPinnedMessagesFeatureEnabled()
+ var canShowMediaGallery by remember { mutableStateOf(false) }
+ LaunchedEffect(Unit) {
+ canShowMediaGallery = featureFlagService.isFeatureEnabled(FeatureFlags.MediaGallery)
+ }
val pinnedMessagesCount by remember { derivedStateOf { roomInfo?.pinnedEventIds?.size } }
LaunchedEffect(Unit) {
@@ -162,6 +167,7 @@ class RoomDetailsPresenter @Inject constructor(
isPublic = isPublic,
heroes = roomInfo?.heroes.orEmpty().toPersistentList(),
canShowPinnedMessages = canShowPinnedMessages,
+ canShowMediaGallery = canShowMediaGallery,
pinnedMessagesCount = pinnedMessagesCount,
canShowKnockRequests = canShowKnockRequests,
knockRequestsCount = knockRequestsCount,
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt
index 7f15c846f9..85b5340959 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt
@@ -40,6 +40,7 @@ data class RoomDetailsState(
val isPublic: Boolean,
val heroes: ImmutableList,
val canShowPinnedMessages: Boolean,
+ val canShowMediaGallery: Boolean,
val pinnedMessagesCount: Int?,
val canShowKnockRequests: Boolean,
val knockRequestsCount: Int?,
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt
index dcf5bc3054..b3a4c0e7ee 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt
@@ -101,6 +101,7 @@ fun aRoomDetailsState(
isPublic: Boolean = true,
heroes: List = emptyList(),
canShowPinnedMessages: Boolean = true,
+ canShowMediaGallery: Boolean = true,
pinnedMessagesCount: Int? = null,
canShowKnockRequests: Boolean = false,
knockRequestsCount: Int? = null,
@@ -126,6 +127,7 @@ fun aRoomDetailsState(
isPublic = isPublic,
heroes = heroes.toPersistentList(),
canShowPinnedMessages = canShowPinnedMessages,
+ canShowMediaGallery = canShowMediaGallery,
pinnedMessagesCount = pinnedMessagesCount,
canShowKnockRequests = canShowKnockRequests,
knockRequestsCount = knockRequestsCount,
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt
index d73b8e2626..5e65ce9336 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt
@@ -101,6 +101,7 @@ fun RoomDetailsView(
invitePeople: () -> Unit,
openAvatarPreview: (name: String, url: String) -> Unit,
openPollHistory: () -> Unit,
+ openMediaGallery: () -> Unit,
openAdminSettings: () -> Unit,
onJoinCallClick: () -> Unit,
onPinnedMessagesClick: () -> Unit,
@@ -219,7 +220,11 @@ fun RoomDetailsView(
PollsSection(
openPollHistory = openPollHistory
)
-
+ if (state.canShowMediaGallery) {
+ MediaGallerySection(
+ onClick = openMediaGallery
+ )
+ }
if (state.isEncrypted) {
SecuritySection()
}
@@ -576,6 +581,19 @@ private fun PollsSection(
}
}
+@Composable
+private fun MediaGallerySection(
+ onClick: () -> Unit,
+) {
+ PreferenceCategory {
+ ListItem(
+ headlineContent = { Text(stringResource(R.string.screen_room_details_media_gallery_title)) },
+ leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Image())),
+ onClick = onClick,
+ )
+ }
+}
+
@Composable
private fun SecuritySection() {
PreferenceCategory(title = stringResource(R.string.screen_room_details_security_title)) {
@@ -631,6 +649,7 @@ private fun ContentToPreview(state: RoomDetailsState) {
invitePeople = {},
openAvatarPreview = { _, _ -> },
openPollHistory = {},
+ openMediaGallery = {},
openAdminSettings = {},
onJoinCallClick = {},
onPinnedMessagesClick = {},
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt
index abbca71b53..11858929e3 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt
@@ -79,6 +79,17 @@ class RoomDetailsViewTest {
}
}
+ @Config(qualifiers = "h1024dp")
+ @Test
+ fun `click on media gallery invokes expected callback`() {
+ ensureCalledOnce { callback ->
+ rule.setRoomDetailView(
+ openMediaGallery = callback,
+ )
+ rule.clickOn(R.string.screen_room_details_media_gallery_title)
+ }
+ }
+
@Config(qualifiers = "h1024dp")
@Test
fun `click on notification invokes expected callback`() {
@@ -241,7 +252,7 @@ class RoomDetailsViewTest {
eventsRecorder.assertSingle(RoomDetailsEvent.SetFavorite(true))
}
- @Config(qualifiers = "h1024dp")
+ @Config(qualifiers = "h1500dp")
@Test
fun `click on leave emit expected Event`() {
val eventsRecorder = EventsRecorder()
@@ -282,6 +293,7 @@ private fun AndroidComposeTestRule.setRoomD
invitePeople: () -> Unit = EnsureNeverCalled(),
openAvatarPreview: (name: String, url: String) -> Unit = EnsureNeverCalledWithTwoParams(),
openPollHistory: () -> Unit = EnsureNeverCalled(),
+ openMediaGallery: () -> Unit = EnsureNeverCalled(),
openAdminSettings: () -> Unit = EnsureNeverCalled(),
onJoinCallClick: () -> Unit = EnsureNeverCalled(),
onPinnedMessagesClick: () -> Unit = EnsureNeverCalled(),
@@ -298,6 +310,7 @@ private fun AndroidComposeTestRule.setRoomD
invitePeople = invitePeople,
openAvatarPreview = openAvatarPreview,
openPollHistory = openPollHistory,
+ openMediaGallery = openMediaGallery,
openAdminSettings = openAdminSettings,
onJoinCallClick = onJoinCallClick,
onPinnedMessagesClick = onPinnedMessagesClick,
diff --git a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt
index ce0d4a07f0..d795f60715 100644
--- a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt
+++ b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt
@@ -30,6 +30,7 @@ import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
@@ -82,6 +83,10 @@ class UserProfileFlowNode @AssistedInject constructor(
override fun onDone() {
backstack.pop()
}
+
+ override fun onViewInTimeline(eventId: EventId) {
+ // Cannot happen
+ }
}
mediaViewerEntryPoint.nodeBuilder(this, buildContext)
.avatar(
diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt
index 8287e2d19d..12aa5c4bfe 100644
--- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt
+++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt
@@ -61,3 +61,10 @@ fun String.replacePrefix(oldPrefix: String, newPrefix: String): String {
this
}
}
+
+/**
+ * Surround with brackets.
+ */
+fun String.withBrackets(prefix: String = "(", suffix: String = ")"): String {
+ return "$prefix$this$suffix"
+}
diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/preview/PreviewUtil.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/preview/PreviewUtil.kt
new file mode 100644
index 0000000000..e4b20fd42b
--- /dev/null
+++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/preview/PreviewUtil.kt
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.core.preview
+
+val loremIpsum = """
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut la
+ bore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris
+ nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate v
+ elit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proide
+ nt, sunt in culpa qui officia deserunt mollit anim id est laborum.
+ """.trimIndent()
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt
index 3f7d087f41..f3c9fb317a 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt
@@ -57,4 +57,6 @@ enum class AvatarSize(val dp: Dp) {
KnockRequestItem(52.dp),
KnockRequestBanner(32.dp),
+
+ MediaSender(32.dp),
}
diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
index 7d21ed1138..9add32499f 100644
--- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
+++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
@@ -154,4 +154,11 @@ enum class FeatureFlags(
defaultValue = { true },
isFinished = false,
),
+ MediaGallery(
+ key = "feature.media_gallery",
+ title = "Allow user to open the media gallery",
+ description = null,
+ defaultValue = { buildMeta -> buildMeta.buildType != BuildType.RELEASE },
+ isFinished = false,
+ ),
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
index 989c301e92..840308af23 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
@@ -107,6 +107,11 @@ interface MatrixRoom : Closeable {
*/
suspend fun pinnedEventsTimeline(): Result
+ /**
+ * Create a new timeline for the media events of the room.
+ */
+ suspend fun mediaTimeline(): Result
+
fun destroy()
suspend fun subscribeToSync()
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt
index 00f7a9a17c..29f8997ace 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt
@@ -42,7 +42,8 @@ interface Timeline : AutoCloseable {
enum class Mode {
LIVE,
FOCUSED_ON_EVENT,
- PINNED_EVENTS
+ PINNED_EVENTS,
+ MEDIA,
}
val membershipChangeEventReceived: Flow
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
index c3057298fa..55f639e266 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
@@ -78,6 +78,7 @@ import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener
import org.matrix.rustcomponents.sdk.RoomInfo
import org.matrix.rustcomponents.sdk.RoomInfoListener
import org.matrix.rustcomponents.sdk.RoomListItem
+import org.matrix.rustcomponents.sdk.RoomMessageEventMessageType
import org.matrix.rustcomponents.sdk.TypingNotificationsListener
import org.matrix.rustcomponents.sdk.UserPowerLevelUpdate
import org.matrix.rustcomponents.sdk.WidgetCapabilities
@@ -223,6 +224,26 @@ class RustMatrixRoom(
}
}
+ override suspend fun mediaTimeline(): Result {
+ return runCatching {
+ innerRoom.messageFilteredTimeline(
+ internalIdPrefix = "MediaGallery_",
+ allowedMessageTypes = listOf(
+ RoomMessageEventMessageType.FILE,
+ RoomMessageEventMessageType.IMAGE,
+ RoomMessageEventMessageType.VIDEO,
+ RoomMessageEventMessageType.AUDIO,
+ )
+ ).let { inner ->
+ createTimeline(inner, mode = Timeline.Mode.MEDIA)
+ }
+ }.onFailure {
+ if (it is CancellationException) {
+ throw it
+ }
+ }
+ }
+
override fun destroy() {
roomCoroutineScope.cancel()
liveTimeline.close()
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt
index 200f1289b4..c8e8b77b32 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt
@@ -56,6 +56,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.launchIn
@@ -182,10 +183,10 @@ class RustTimeline(
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 {
+ updatePaginationStatus(direction) { it.copy(isPaginating = false) }
Timber.e(error, "Error paginating $direction on room ${matrixRoom.roomId}")
}
}.onSuccess { hasReachedEnd ->
@@ -211,13 +212,13 @@ class RustTimeline(
override val timelineItems: Flow> = combine(
_timelineItems,
- backPaginationStatus.map { it.hasMoreToLoad }.distinctUntilChanged(),
- forwardPaginationStatus.map { it.hasMoreToLoad }.distinctUntilChanged(),
+ backPaginationStatus.filter { !it.isPaginating }.distinctUntilChanged(),
+ forwardPaginationStatus.filter { !it.isPaginating }.distinctUntilChanged(),
matrixRoom.roomInfoFlow.map { it.creator },
isTimelineInitialized,
) { timelineItems,
- hasMoreToLoadBackward,
- hasMoreToLoadForward,
+ backwardPaginationStatus,
+ forwardPaginationStatus,
roomCreator,
isTimelineInitialized ->
withContext(dispatcher) {
@@ -227,15 +228,15 @@ class RustTimeline(
items = items,
isDm = matrixRoom.isDm,
roomCreator = roomCreator,
- hasMoreToLoadBackwards = hasMoreToLoadBackward,
+ hasMoreToLoadBackwards = backwardPaginationStatus.hasMoreToLoad,
)
}
.let { items ->
loadingIndicatorsPostProcessor.process(
items = items,
isTimelineInitialized = isTimelineInitialized,
- hasMoreToLoadBackward = hasMoreToLoadBackward,
- hasMoreToLoadForward = hasMoreToLoadForward
+ hasMoreToLoadBackward = backwardPaginationStatus.hasMoreToLoad,
+ hasMoreToLoadForward = forwardPaginationStatus.hasMoreToLoad,
)
}
.let { items ->
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
index 9974e36746..41d913b89c 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
@@ -133,6 +133,7 @@ class FakeMatrixRoom(
private val getMembersResult: (Int) -> Result> = { lambdaError() },
private val timelineFocusedOnEventResult: (EventId) -> Result = { lambdaError() },
private val pinnedEventsTimelineResult: () -> Result = { lambdaError() },
+ private val mediaTimelineResult: () -> Result = { lambdaError() },
private val setSendQueueEnabledLambda: (Boolean) -> Unit = { _: Boolean -> },
private val saveComposerDraftLambda: (ComposerDraft) -> Result = { _: ComposerDraft -> Result.success(Unit) },
private val loadComposerDraftLambda: () -> Result = { Result.success(null) },
@@ -203,6 +204,10 @@ class FakeMatrixRoom(
pinnedEventsTimelineResult()
}
+ override suspend fun mediaTimeline(): Result = simulateLongTask {
+ mediaTimelineResult()
+ }
+
override suspend fun subscribeToSync() {
subscribeToSyncLambda()
}
diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaGalleryEntryPoint.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaGalleryEntryPoint.kt
new file mode 100644
index 0000000000..a26bb18915
--- /dev/null
+++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaGalleryEntryPoint.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.mediaviewer.api
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import io.element.android.libraries.architecture.FeatureEntryPoint
+import io.element.android.libraries.matrix.api.core.EventId
+
+interface MediaGalleryEntryPoint : FeatureEntryPoint {
+ fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
+
+ interface NodeBuilder {
+ fun callback(callback: Callback): NodeBuilder
+ fun build(): Node
+ }
+
+ interface Callback : Plugin {
+ fun onBackClick()
+ fun onViewInTimeline(eventId: EventId)
+ }
+}
diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt
index 5c317b1d6a..17a1052954 100644
--- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt
+++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt
@@ -9,6 +9,7 @@ package io.element.android.libraries.mediaviewer.api
import android.os.Parcelable
import io.element.android.libraries.core.mimetype.MimeTypes
+import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.parcelize.Parcelize
@Parcelize
@@ -18,11 +19,14 @@ data class MediaInfo(
val mimeType: String,
val formattedFileSize: String,
val fileExtension: String,
+ val senderId: UserId?,
val senderName: String?,
+ val senderAvatar: String?,
val dateSent: String?,
) : Parcelable
fun anImageMediaInfo(
+ senderId: UserId? = UserId("@alice:server.org"),
caption: String? = null,
senderName: String? = null,
dateSent: String? = null,
@@ -32,7 +36,9 @@ fun anImageMediaInfo(
mimeType = MimeTypes.Jpeg,
formattedFileSize = "4MB",
fileExtension = "jpg",
+ senderId = senderId,
senderName = senderName,
+ senderAvatar = null,
dateSent = dateSent,
)
@@ -46,24 +52,31 @@ fun aVideoMediaInfo(
mimeType = MimeTypes.Mp4,
formattedFileSize = "14MB",
fileExtension = "mp4",
+ senderId = UserId("@alice:server.org"),
senderName = senderName,
+ senderAvatar = null,
dateSent = dateSent,
)
fun aPdfMediaInfo(
+ filename: String = "a pdf file.pdf",
+ caption: String? = null,
senderName: String? = null,
dateSent: String? = null,
): MediaInfo = MediaInfo(
- filename = "a pdf file.pdf",
- caption = null,
+ filename = filename,
+ caption = caption,
mimeType = MimeTypes.Pdf,
formattedFileSize = "23MB",
fileExtension = "pdf",
+ senderId = UserId("@alice:server.org"),
senderName = senderName,
+ senderAvatar = null,
dateSent = dateSent,
)
fun anApkMediaInfo(
+ senderId: UserId? = UserId("@alice:server.org"),
senderName: String? = null,
dateSent: String? = null,
): MediaInfo = MediaInfo(
@@ -72,7 +85,9 @@ fun anApkMediaInfo(
mimeType = MimeTypes.Apk,
formattedFileSize = "50MB",
fileExtension = "apk",
+ senderId = senderId,
senderName = senderName,
+ senderAvatar = null,
dateSent = dateSent,
)
@@ -85,6 +100,8 @@ fun anAudioMediaInfo(
mimeType = MimeTypes.Mp3,
formattedFileSize = "7MB",
fileExtension = "mp3",
+ senderId = UserId("@alice:server.org"),
senderName = senderName,
+ senderAvatar = null,
dateSent = dateSent,
)
diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt
index fb5ee5dece..3e262c08f2 100644
--- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt
+++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt
@@ -12,6 +12,7 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs
+import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
interface MediaViewerEntryPoint : FeatureEntryPoint {
@@ -26,12 +27,15 @@ interface MediaViewerEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun onDone()
+ fun onViewInTimeline(eventId: EventId)
}
data class Params(
+ val eventId: EventId?,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
+ val canShowInfo: Boolean,
val canDownload: Boolean,
val canShare: Boolean,
) : NodeInputs
diff --git a/libraries/mediaviewer/impl/build.gradle.kts b/libraries/mediaviewer/impl/build.gradle.kts
index 5ebc252343..4fa63820d3 100644
--- a/libraries/mediaviewer/impl/build.gradle.kts
+++ b/libraries/mediaviewer/impl/build.gradle.kts
@@ -39,9 +39,11 @@ dependencies {
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 +51,11 @@ dependencies {
implementation(projects.libraries.di)
implementation(projects.libraries.matrix.api)
+ testImplementation(projects.libraries.dateformatter.test)
+ testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.mediaviewer.test)
+ testImplementation(projects.services.toolbox.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaGalleryEntryPoint.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaGalleryEntryPoint.kt
new file mode 100644
index 0000000000..5d4fd8b297
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaGalleryEntryPoint.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.mediaviewer.impl
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint
+import io.element.android.libraries.mediaviewer.impl.gallery.root.MediaGalleryRootNode
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class DefaultMediaGalleryEntryPoint @Inject constructor() : MediaGalleryEntryPoint {
+ override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): MediaGalleryEntryPoint.NodeBuilder {
+ val plugins = ArrayList()
+
+ return object : MediaGalleryEntryPoint.NodeBuilder {
+ override fun callback(callback: MediaGalleryEntryPoint.Callback): MediaGalleryEntryPoint.NodeBuilder {
+ plugins += callback
+ return this
+ }
+
+ override fun build(): Node {
+ return parentNode.createNode(buildContext, plugins)
+ }
+ }
+ }
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt
index 86d7bca722..f9611a7023 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt
@@ -14,6 +14,7 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
@@ -41,17 +42,21 @@ class DefaultMediaViewerEntryPoint @Inject constructor() : MediaViewerEntryPoint
val mimeType = MimeTypes.Images
return params(
MediaViewerEntryPoint.Params(
+ eventId = null,
mediaInfo = MediaInfo(
filename = filename,
caption = null,
mimeType = mimeType,
formattedFileSize = "",
fileExtension = "",
+ senderId = UserId("@dummy:server.org"),
senderName = null,
+ senderAvatar = null,
dateSent = null,
),
mediaSource = MediaSource(url = avatarUrl),
thumbnailSource = null,
+ canShowInfo = false,
canDownload = false,
canShare = false,
)
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaBottomSheetState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaBottomSheetState.kt
new file mode 100644
index 0000000000..c55e3c2295
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaBottomSheetState.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.mediaviewer.impl.details
+
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.media.MediaSource
+import io.element.android.libraries.mediaviewer.api.MediaInfo
+
+sealed interface MediaBottomSheetState {
+ data object Hidden : MediaBottomSheetState
+
+ data class MediaDeleteConfirmationState(
+ val eventId: EventId,
+ val mediaInfo: MediaInfo,
+ val thumbnailSource: MediaSource?,
+ ) : MediaBottomSheetState
+
+ data class MediaDetailsBottomSheetState(
+ val eventId: EventId?,
+ val canDelete: Boolean,
+ val mediaInfo: MediaInfo,
+ val thumbnailSource: MediaSource?,
+ ) : MediaBottomSheetState
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheet.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheet.kt
new file mode 100644
index 0000000000..b8e0075504
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheet.kt
@@ -0,0 +1,161 @@
+/*
+ * 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.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.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
+ ) {
+ Box(
+ modifier = Modifier
+ .size(40.dp),
+ ) {
+ 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 = aMediaDeleteConfirmationState(),
+ onDelete = {},
+ onDismiss = {},
+ )
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheet.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheet.kt
new file mode 100644
index 0000000000..a11abe945b
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheet.kt
@@ -0,0 +1,202 @@
+/*
+ * 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.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 = aMediaDetailsBottomSheetState(),
+ onViewInTimeline = {},
+ onDelete = {},
+ onDismiss = {},
+ )
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/Preview.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/Preview.kt
new file mode 100644
index 0000000000..880fcb2b91
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/Preview.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.mediaviewer.impl.details
+
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
+
+fun aMediaDetailsBottomSheetState(): MediaBottomSheetState.MediaDetailsBottomSheetState {
+ return MediaBottomSheetState.MediaDetailsBottomSheetState(
+ eventId = EventId("\$eventId"),
+ canDelete = true,
+ mediaInfo = anImageMediaInfo(
+ senderName = "Alice",
+ ),
+ thumbnailSource = null,
+ )
+}
+
+fun aMediaDeleteConfirmationState(): MediaBottomSheetState.MediaDeleteConfirmationState {
+ return MediaBottomSheetState.MediaDeleteConfirmationState(
+ eventId = EventId("\$eventId"),
+ mediaInfo = anImageMediaInfo(
+ senderName = "Alice",
+ ),
+ thumbnailSource = null,
+ )
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt
new file mode 100644
index 0000000000..6b96500149
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt
@@ -0,0 +1,185 @@
+/*
+ * 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.androidutils.filesize.FileSizeFormatter
+import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
+import io.element.android.libraries.dateformatter.api.toHumanReadableDuration
+import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
+import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
+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 javax.inject.Inject
+
+class EventItemFactory @Inject constructor(
+ private val fileSizeFormatter: FileSizeFormatter,
+ private val fileExtensionExtractor: FileExtensionExtractor,
+ private val lastMessageTimestampFormatter: LastMessageTimestampFormatter,
+) {
+ fun create(
+ currentTimelineItem: MatrixTimelineItem.Event,
+ ): MediaItem.Event? {
+ val event = currentTimelineItem.event
+ val sentTime = lastMessageTimestampFormatter.format(currentTimelineItem.event.timestamp)
+ return when (val content = event.content) {
+ CallNotifyContent,
+ is FailedToParseMessageLikeContent,
+ is FailedToParseStateContent,
+ LegacyCallInviteContent,
+ is PollContent,
+ is ProfileChangeContent,
+ RedactedContent,
+ is RoomMembershipContent,
+ is StateContent,
+ is StickerContent,
+ is UnableToDecryptContent,
+ UnknownContent -> {
+ Timber.w("Should not happen: ${content.javaClass.simpleName}")
+ null
+ }
+ is MessageContent -> {
+ when (val type = content.type) {
+ is EmoteMessageType,
+ is NoticeMessageType,
+ is OtherMessageType,
+ is LocationMessageType,
+ is TextMessageType -> {
+ Timber.w("Should not happen: ${content.type}")
+ null
+ }
+ is AudioMessageType -> MediaItem.File(
+ id = currentTimelineItem.uniqueId,
+ eventId = currentTimelineItem.eventId,
+ mediaInfo = MediaInfo(
+ filename = type.filename,
+ caption = type.caption,
+ mimeType = type.info?.mimetype.orEmpty(),
+ formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(),
+ fileExtension = fileExtensionExtractor.extractFromName(type.filename),
+ senderId = event.sender,
+ senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender),
+ senderAvatar = event.senderProfile.getAvatarUrl(),
+ dateSent = sentTime,
+ ),
+ mediaSource = type.source,
+ )
+ is FileMessageType -> MediaItem.File(
+ id = currentTimelineItem.uniqueId,
+ eventId = currentTimelineItem.eventId,
+ mediaInfo = MediaInfo(
+ filename = type.filename,
+ caption = type.caption,
+ mimeType = type.info?.mimetype.orEmpty(),
+ formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(),
+ fileExtension = fileExtensionExtractor.extractFromName(type.filename),
+ senderId = event.sender,
+ senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender),
+ senderAvatar = event.senderProfile.getAvatarUrl(),
+ dateSent = sentTime,
+ ),
+ mediaSource = type.source,
+ )
+ is ImageMessageType -> MediaItem.Image(
+ id = currentTimelineItem.uniqueId,
+ eventId = currentTimelineItem.eventId,
+ mediaInfo = MediaInfo(
+ filename = type.filename,
+ caption = type.caption,
+ mimeType = type.info?.mimetype.orEmpty(),
+ formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(),
+ fileExtension = fileExtensionExtractor.extractFromName(type.filename),
+ senderId = event.sender,
+ senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender),
+ senderAvatar = event.senderProfile.getAvatarUrl(),
+ dateSent = sentTime,
+ ),
+ mediaSource = type.source,
+ thumbnailSource = null,
+ )
+ is StickerMessageType -> MediaItem.Image(
+ id = currentTimelineItem.uniqueId,
+ eventId = currentTimelineItem.eventId,
+ mediaInfo = MediaInfo(
+ filename = type.filename,
+ caption = type.caption,
+ mimeType = type.info?.mimetype.orEmpty(),
+ formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(),
+ fileExtension = fileExtensionExtractor.extractFromName(type.filename),
+ senderId = event.sender,
+ senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender),
+ senderAvatar = event.senderProfile.getAvatarUrl(),
+ dateSent = sentTime,
+ ),
+ mediaSource = type.source,
+ thumbnailSource = null,
+ )
+ is VideoMessageType -> MediaItem.Video(
+ id = currentTimelineItem.uniqueId,
+ eventId = currentTimelineItem.eventId,
+ mediaInfo = MediaInfo(
+ filename = type.filename,
+ caption = type.caption,
+ mimeType = type.info?.mimetype.orEmpty(),
+ formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(),
+ fileExtension = fileExtensionExtractor.extractFromName(type.filename),
+ senderId = event.sender,
+ senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender),
+ senderAvatar = event.senderProfile.getAvatarUrl(),
+ dateSent = sentTime,
+ ),
+ mediaSource = type.source,
+ thumbnailSource = type.info?.thumbnailSource,
+ duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
+ )
+ is VoiceMessageType -> MediaItem.File(
+ id = currentTimelineItem.uniqueId,
+ eventId = currentTimelineItem.eventId,
+ mediaInfo = MediaInfo(
+ filename = type.filename,
+ caption = type.caption,
+ mimeType = type.info?.mimetype.orEmpty(),
+ formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(),
+ fileExtension = fileExtensionExtractor.extractFromName(type.filename),
+ senderId = event.sender,
+ senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender),
+ senderAvatar = event.senderProfile.getAvatarUrl(),
+ dateSent = sentTime,
+ ),
+ mediaSource = type.source,
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryEvents.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryEvents.kt
new file mode 100644
index 0000000000..717ba4edbb
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryEvents.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.mediaviewer.impl.gallery
+
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.media.MediaSource
+import io.element.android.libraries.matrix.api.timeline.Timeline
+import io.element.android.libraries.mediaviewer.api.MediaInfo
+
+sealed interface MediaGalleryEvents {
+ data class ChangeMode(val mode: MediaGalleryMode) : MediaGalleryEvents
+ data class LoadMore(val direction: Timeline.PaginationDirection) : MediaGalleryEvents
+ data class Share(val mediaItem: MediaItem.Event) : MediaGalleryEvents
+ data class SaveOnDisk(val mediaItem: MediaItem.Event) : MediaGalleryEvents
+ data class OpenInfo(val mediaItem: MediaItem.Event) : MediaGalleryEvents
+ data class ViewInTimeline(val eventId: EventId) : MediaGalleryEvents
+
+ data class ConfirmDelete(
+ val eventId: EventId,
+ val mediaInfo: MediaInfo,
+ val thumbnailSource: MediaSource?,
+ ) : MediaGalleryEvents
+
+ data object CloseBottomSheet : MediaGalleryEvents
+ data class Delete(val eventId: EventId) : MediaGalleryEvents
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNavigator.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNavigator.kt
new file mode 100644
index 0000000000..7ae729309a
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNavigator.kt
@@ -0,0 +1,14 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.mediaviewer.impl.gallery
+
+import io.element.android.libraries.matrix.api.core.EventId
+
+interface MediaGalleryNavigator {
+ fun onViewInTimelineClick(eventId: EventId)
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNode.kt
new file mode 100644
index 0000000000..ccea1a130e
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNode.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.mediaviewer.impl.gallery
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.core.plugin.plugins
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.libraries.di.RoomScope
+import io.element.android.libraries.matrix.api.core.EventId
+
+@ContributesNode(RoomScope::class)
+class MediaGalleryNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ presenterFactory: MediaGalleryPresenter.Factory,
+) : Node(buildContext, plugins = plugins),
+ MediaGalleryNavigator {
+ private val presenter = presenterFactory.create(
+ navigator = this,
+ )
+
+ interface Callback : Plugin {
+ fun onBackClick()
+ fun onItemClick(item: MediaItem.Event)
+ fun onViewInTimeline(eventId: EventId)
+ }
+
+ private fun onBackClick() {
+ plugins().forEach {
+ it.onBackClick()
+ }
+ }
+
+ override fun onViewInTimelineClick(eventId: EventId) {
+ plugins().forEach {
+ it.onViewInTimeline(eventId)
+ }
+ }
+
+ private fun onItemClick(item: MediaItem.Event) {
+ plugins().forEach {
+ it.onItemClick(item)
+ }
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ MediaGalleryView(
+ state = state,
+ onBackClick = ::onBackClick,
+ onItemClick = ::onItemClick,
+ modifier = modifier,
+ )
+ }
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt
new file mode 100644
index 0000000000..c122e95447
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt
@@ -0,0 +1,255 @@
+/*
+ * 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.DisposableEffect
+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 timelineMediaItemsFactory: TimelineMediaItemsFactory,
+ private val localMediaFactory: LocalMediaFactory,
+ private val mediaLoader: MatrixMediaLoader,
+ private val localMediaActions: LocalMediaActions,
+ private val snackbarDispatcher: SnackbarDispatcher,
+ private val mediaItemsPostProcessor: MediaItemsPostProcessor,
+) : Presenter {
+ @AssistedFactory
+ interface Factory {
+ fun create(
+ navigator: MediaGalleryNavigator,
+ ): MediaGalleryPresenter
+ }
+
+ @Composable
+ override fun present(): MediaGalleryState {
+ val coroutineScope = rememberCoroutineScope()
+ var mode by remember { mutableStateOf(MediaGalleryMode.Images) }
+
+ val roomInfo by room.roomInfoFlow.collectAsState(null)
+
+ var mediaBottomSheetState by remember { mutableStateOf(MediaBottomSheetState.Hidden) }
+
+ var mediaItems by remember {
+ mutableStateOf>>(AsyncData.Uninitialized)
+ }
+ val groupedMediaItems by remember {
+ derivedStateOf {
+ mediaItemsPostProcessor.process(
+ mediaItems = mediaItems,
+ )
+ }
+ }
+ val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
+ localMediaActions.Configure()
+
+ var timeline by remember { mutableStateOf>(AsyncData.Uninitialized) }
+ LaunchedEffect(Unit) {
+ room.mediaTimeline()
+ .fold(
+ { timeline = AsyncData.Success(it) },
+ { timeline = AsyncData.Failure(it) },
+ )
+ }
+ DisposableEffect(Unit) {
+ onDispose {
+ timeline.dataOrNull()?.close()
+ }
+ }
+
+ MediaListEffect(
+ timeline = timeline,
+ onItemsChange = { newItems ->
+ mediaItems = newItems
+ }
+ )
+
+ fun handleEvents(event: MediaGalleryEvents) {
+ when (event) {
+ is MediaGalleryEvents.ChangeMode -> {
+ mode = event.mode
+ }
+ is MediaGalleryEvents.LoadMore -> coroutineScope.launch {
+ timeline.dataOrNull()?.paginate(event.direction)
+ }
+ is MediaGalleryEvents.Delete -> coroutineScope.delete(timeline, event.eventId)
+ is MediaGalleryEvents.SaveOnDisk -> coroutineScope.saveOnDisk(event.mediaItem)
+ is MediaGalleryEvents.Share -> coroutineScope.share(event.mediaItem)
+ is MediaGalleryEvents.ViewInTimeline -> {
+ 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,
+ groupedMediaItems = groupedMediaItems,
+ mediaBottomSheetState = mediaBottomSheetState,
+ snackbarMessage = snackbarMessage,
+ eventSink = ::handleEvents
+ )
+ }
+
+ @Composable
+ private fun MediaListEffect(
+ timeline: AsyncData,
+ onItemsChange: (AsyncData>) -> Unit,
+ ) {
+ val updatedOnItemsChange by rememberUpdatedState(onItemsChange)
+
+ LaunchedEffect(timeline) {
+ when (timeline) {
+ AsyncData.Uninitialized -> flowOf(AsyncData.Uninitialized)
+ is AsyncData.Failure -> flowOf(AsyncData.Failure(timeline.error))
+ is AsyncData.Loading -> flowOf(AsyncData.Loading())
+ is AsyncData.Success -> {
+ timeline.data.timelineItems
+ .onEach { items ->
+ timelineMediaItemsFactory.replaceWith(
+ timelineItems = items,
+ )
+ }
+ .launchIn(this)
+
+ timelineMediaItemsFactory.timelineItems.map { timelineItems ->
+ AsyncData.Success(timelineItems)
+ }
+ }
+ }
+ .onEach { items ->
+ updatedOnItemsChange(items)
+ }
+ .launchIn(this)
+ }
+ }
+
+ private fun CoroutineScope.delete(
+ timeline: AsyncData,
+ eventId: EventId,
+ ) = launch {
+ timeline.dataOrNull()?.redactEvent(
+ eventOrTransactionId = eventId.toEventOrTransactionId(),
+ reason = null,
+ )
+ }
+
+ private suspend fun downloadMedia(mediaItem: MediaItem.Event): Result {
+ return mediaLoader.downloadMediaFile(
+ source = mediaItem.mediaSource(),
+ mimeType = mediaItem.mediaInfo().mimeType,
+ filename = mediaItem.mediaInfo().filename
+ )
+ .mapCatching { mediaFile ->
+ localMediaFactory.createFromMediaFile(
+ mediaFile = mediaFile,
+ mediaInfo = mediaItem.mediaInfo()
+ )
+ }
+ }
+
+ private fun CoroutineScope.saveOnDisk(mediaItem: MediaItem.Event) = launch {
+ downloadMedia(mediaItem)
+ .mapCatching { localMedia ->
+ localMediaActions.saveOnDisk(localMedia)
+ }
+ .onSuccess {
+ val snackbarMessage = SnackbarMessage(CommonStrings.common_file_saved_on_disk_android)
+ snackbarDispatcher.post(snackbarMessage)
+ }
+ .onFailure {
+ val snackbarMessage = SnackbarMessage(mediaActionsError(it))
+ snackbarDispatcher.post(snackbarMessage)
+ }
+ }
+
+ private fun CoroutineScope.share(mediaItem: MediaItem.Event) = launch {
+ downloadMedia(mediaItem)
+ .mapCatching { localMedia ->
+ localMediaActions.share(localMedia)
+ }
+ .onFailure {
+ val snackbarMessage = SnackbarMessage(mediaActionsError(it))
+ snackbarDispatcher.post(snackbarMessage)
+ }
+ }
+
+ private fun mediaActionsError(throwable: Throwable): Int {
+ return if (throwable is ActivityNotFoundException) {
+ R.string.error_no_compatible_app_found
+ } else {
+ CommonStrings.error_unknown
+ }
+ }
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryState.kt
new file mode 100644
index 0000000000..51ae794175
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryState.kt
@@ -0,0 +1,33 @@
+/*
+ * 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 groupedMediaItems: AsyncData,
+ val mediaBottomSheetState: MediaBottomSheetState,
+ val snackbarMessage: SnackbarMessage?,
+ val eventSink: (MediaGalleryEvents) -> Unit,
+)
+
+data class GroupedMediaItems(
+ val imageAndVideoItems: ImmutableList,
+ val fileItems: ImmutableList,
+)
+
+enum class MediaGalleryMode(val stringResource: Int) {
+ Images(R.string.screen_media_browser_list_mode_media),
+ Files(R.string.screen_media_browser_list_mode_files),
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt
new file mode 100644
index 0000000000..58d566dddd
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.mediaviewer.impl.gallery
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.matrix.api.core.UniqueId
+import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
+import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState
+import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemDateSeparator
+import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemFile
+import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage
+import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemLoadingIndicator
+import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemVideo
+import kotlinx.collections.immutable.toImmutableList
+
+open class MediaGalleryStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aMediaGalleryState(),
+ aMediaGalleryState(groupedMediaItems = AsyncData.Loading()),
+ aMediaGalleryState(groupedMediaItems = AsyncData.Success(aGroupedMediaItems())),
+ aMediaGalleryState(
+ groupedMediaItems = AsyncData.Success(
+ aGroupedMediaItems(
+ imageAndVideoItems = listOf(
+ aMediaItemDateSeparator(id = UniqueId("0")),
+ aMediaItemImage(id = UniqueId("1")),
+ aMediaItemDateSeparator(
+ id = UniqueId("2"),
+ formattedDate = "September 2004",
+ ),
+ aMediaItemImage(id = UniqueId("3")),
+ aMediaItemVideo(id = UniqueId("4")),
+ aMediaItemImage(id = UniqueId("5")),
+ aMediaItemImage(id = UniqueId("6")),
+ aMediaItemImage(id = UniqueId("7")),
+ aMediaItemImage(id = UniqueId("8")),
+ aMediaItemImage(id = UniqueId("9")),
+ aMediaItemLoadingIndicator(),
+ ).toImmutableList()
+ )
+ ),
+ ),
+ aMediaGalleryState(mode = MediaGalleryMode.Files),
+ aMediaGalleryState(mode = MediaGalleryMode.Files, groupedMediaItems = AsyncData.Loading()),
+ aMediaGalleryState(mode = MediaGalleryMode.Files, groupedMediaItems = AsyncData.Success(aGroupedMediaItems())),
+ aMediaGalleryState(
+ mode = MediaGalleryMode.Files,
+ groupedMediaItems = AsyncData.Success(
+ aGroupedMediaItems(
+ fileItems = listOf(
+ aMediaItemDateSeparator(id = UniqueId("0")),
+ aMediaItemFile(id = UniqueId("1")),
+ aMediaItemDateSeparator(
+ id = UniqueId("2"),
+ formattedDate = "September 2004",
+ ),
+ aMediaItemFile(id = UniqueId("3")),
+ aMediaItemFile(id = UniqueId("4")),
+ aMediaItemLoadingIndicator(),
+ ).toImmutableList()
+ )
+ ),
+ ),
+ aMediaGalleryState(mediaBottomSheetState = aMediaDetailsBottomSheetState()),
+ aMediaGalleryState(
+ groupedMediaItems = AsyncData.Failure(Exception("Failed to load media")),
+ ),
+ aMediaGalleryState(
+ mode = MediaGalleryMode.Files,
+ groupedMediaItems = AsyncData.Failure(Exception("Failed to load media")),
+ ),
+ )
+}
+
+private fun aMediaGalleryState(
+ roomName: String = "Room name",
+ mode: MediaGalleryMode = MediaGalleryMode.Images,
+ groupedMediaItems: AsyncData = AsyncData.Uninitialized,
+ mediaBottomSheetState: MediaBottomSheetState = MediaBottomSheetState.Hidden,
+) = MediaGalleryState(
+ roomName = roomName,
+ mode = mode,
+ groupedMediaItems = groupedMediaItems,
+ mediaBottomSheetState = mediaBottomSheetState,
+ snackbarMessage = null,
+ eventSink = {}
+)
+
+private fun aGroupedMediaItems(
+ imageAndVideoItems: List = emptyList(),
+ fileItems: List = emptyList(),
+) = GroupedMediaItems(
+ imageAndVideoItems = imageAndVideoItems.toImmutableList(),
+ fileItems = fileItems.toImmutableList(),
+)
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt
new file mode 100644
index 0000000000..54bb6faf3b
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt
@@ -0,0 +1,436 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.mediaviewer.impl.gallery
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.GridItemSpan
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.designsystem.components.BigIcon
+import io.element.android.libraries.designsystem.components.PageTitle
+import io.element.android.libraries.designsystem.components.async.AsyncFailure
+import io.element.android.libraries.designsystem.components.button.BackButton
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+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]
+ MediaGalleryPage(
+ mode = mode,
+ state = state,
+ 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 MediaGalleryPage(
+ mode: MediaGalleryMode,
+ state: MediaGalleryState,
+ onItemClick: (MediaItem.Event) -> Unit,
+) {
+ when (val groupedMediaItems = state.groupedMediaItems) {
+ AsyncData.Uninitialized,
+ is AsyncData.Loading -> {
+ LoadingContent(mode)
+ }
+ is AsyncData.Success -> {
+ when (mode) {
+ MediaGalleryMode.Images -> MediaGalleryImages(
+ imagesAndVideos = groupedMediaItems.data.imageAndVideoItems,
+ eventSink = state.eventSink,
+ onItemClick = onItemClick,
+ )
+ MediaGalleryMode.Files -> MediaGalleryFiles(
+ files = groupedMediaItems.data.fileItems,
+ eventSink = state.eventSink,
+ onItemClick = onItemClick,
+ )
+ }
+ }
+ is AsyncData.Failure -> {
+ ErrorContent(
+ error = groupedMediaItems.error,
+ )
+ }
+ }
+}
+
+@Composable
+private fun MediaGalleryImages(
+ imagesAndVideos: ImmutableList,
+ eventSink: (MediaGalleryEvents) -> Unit,
+ onItemClick: (MediaItem.Event) -> Unit,
+) {
+ if (imagesAndVideos.isEmpty()) {
+ EmptyContent()
+ } else {
+ MediaGalleryImageGrid(
+ imagesAndVideos = imagesAndVideos,
+ eventSink = eventSink,
+ onItemClick = onItemClick,
+ )
+ }
+}
+
+@Composable
+private fun MediaGalleryFiles(
+ files: ImmutableList,
+ eventSink: (MediaGalleryEvents) -> Unit,
+ onItemClick: (MediaItem.Event) -> Unit,
+) {
+ if (files.isEmpty()) {
+ EmptyContent()
+ } else {
+ MediaGalleryFilesList(
+ files = files,
+ eventSink = eventSink,
+ onItemClick = onItemClick,
+ )
+ }
+}
+
+@Composable
+private fun MediaGalleryFilesList(
+ files: ImmutableList,
+ eventSink: (MediaGalleryEvents) -> Unit,
+ onItemClick: (MediaItem.Event) -> Unit,
+) {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ items(files) { item ->
+ when (item) {
+ is MediaItem.File -> FileItemView(
+ item,
+ onClick = { onItemClick(item) },
+ onShareClick = { eventSink(MediaGalleryEvents.Share(item)) },
+ onDownloadClick = { eventSink(MediaGalleryEvents.SaveOnDisk(item)) },
+ onInfoClick = { eventSink(MediaGalleryEvents.OpenInfo(item)) },
+ )
+ is MediaItem.DateSeparator -> DateItemView(item)
+ is MediaItem.Image,
+ is MediaItem.Video -> {
+ // Should not happen
+ }
+ is MediaItem.LoadingIndicator -> {
+ LoadingMoreIndicator(item.direction)
+ val latestEventSink by rememberUpdatedState(eventSink)
+ LaunchedEffect(item.timestamp) {
+ latestEventSink(MediaGalleryEvents.LoadMore(item.direction))
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun MediaGalleryImageGrid(
+ imagesAndVideos: ImmutableList,
+ eventSink: (MediaGalleryEvents) -> Unit,
+ onItemClick: (MediaItem.Event) -> Unit,
+) {
+ val configuration = LocalConfiguration.current
+ val screenWidth = configuration.screenWidthDp.dp
+ val horizontalPadding = 16.dp
+ val itemSpacing = 4.dp
+ val availableWidth = screenWidth - horizontalPadding * 2
+ val minCellWidth = 80.dp
+ // Calculate the number of columns
+ val columns = max(1, (availableWidth / (minCellWidth + itemSpacing)).toInt())
+ LazyVerticalGrid(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = horizontalPadding),
+ columns = GridCells.Fixed(columns),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ items(
+ imagesAndVideos,
+ span = { item ->
+ when (item) {
+ is MediaItem.LoadingIndicator,
+ is MediaItem.DateSeparator -> GridItemSpan(columns)
+ is MediaItem.Event -> 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(
+ image = item,
+ onClick = { onItemClick(item) },
+ onLongClick = {
+ eventSink(MediaGalleryEvents.OpenInfo(item))
+ },
+ )
+ }
+ is MediaItem.Video -> {
+ VideoItemView(
+ video = 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) {
+ AsyncFailure(
+ throwable = error,
+ onRetry = null,
+ modifier = Modifier.fillMaxSize(),
+ )
+}
+
+@Composable
+private fun EmptyContent() {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ PageTitle(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 44.dp)
+ .padding(24.dp),
+ title = stringResource(R.string.screen_media_browser_empty_state_title),
+ iconStyle = BigIcon.Style.Default(CompoundIcons.Image()),
+ subtitle = stringResource(R.string.screen_media_browser_empty_state_subtitle),
+ )
+ }
+}
+
+@Composable
+private fun LoadingContent(
+ mode: MediaGalleryMode,
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(top = 48.dp)
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ CircularProgressIndicator()
+ val res = when (mode) {
+ MediaGalleryMode.Images -> R.string.screen_media_browser_list_loading_media
+ MediaGalleryMode.Files -> R.string.screen_media_browser_list_loading_files
+ }
+ Text(
+ text = stringResource(res),
+ modifier = Modifier.align(Alignment.CenterHorizontally),
+ )
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun MediaGalleryViewPreview(
+ @PreviewParameter(MediaGalleryStateProvider::class) state: MediaGalleryState
+) = ElementPreview {
+ MediaGalleryView(
+ state = state,
+ onBackClick = {},
+ onItemClick = {},
+ )
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItem.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItem.kt
new file mode 100644
index 0000000000..f43387fdb6
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItem.kt
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.mediaviewer.impl.gallery
+
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.core.UniqueId
+import io.element.android.libraries.matrix.api.media.MediaSource
+import io.element.android.libraries.matrix.api.timeline.Timeline
+import io.element.android.libraries.matrix.ui.media.MediaRequestData
+import io.element.android.libraries.mediaviewer.api.MediaInfo
+
+sealed interface MediaItem {
+ data class DateSeparator(
+ val id: UniqueId,
+ val formattedDate: String,
+ ) : MediaItem
+
+ data class LoadingIndicator(
+ val id: UniqueId,
+ val direction: Timeline.PaginationDirection,
+ val timestamp: Long,
+ ) : MediaItem
+
+ sealed interface Event : MediaItem
+
+ data class Image(
+ val id: UniqueId,
+ val eventId: EventId?,
+ val mediaInfo: MediaInfo,
+ val mediaSource: MediaSource,
+ val thumbnailSource: MediaSource?,
+ ) : Event {
+ val thumbnailMediaRequestData: MediaRequestData
+ get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
+ }
+
+ data class Video(
+ val id: UniqueId,
+ val eventId: EventId?,
+ val mediaInfo: MediaInfo,
+ val mediaSource: MediaSource,
+ val thumbnailSource: MediaSource?,
+ val duration: String?,
+ ) : Event {
+ val thumbnailMediaRequestData: MediaRequestData
+ get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
+ }
+
+ data class File(
+ val id: UniqueId,
+ val eventId: EventId?,
+ val mediaInfo: MediaInfo,
+ val mediaSource: MediaSource,
+ ) : Event
+}
+
+fun MediaItem.id(): UniqueId {
+ return when (this) {
+ is MediaItem.DateSeparator -> id
+ is MediaItem.LoadingIndicator -> id
+ is MediaItem.Image -> id
+ is MediaItem.Video -> id
+ is MediaItem.File -> id
+ }
+}
+
+fun MediaItem.Event.eventId(): EventId? {
+ return when (this) {
+ is MediaItem.Image -> eventId
+ is MediaItem.Video -> eventId
+ is MediaItem.File -> eventId
+ }
+}
+
+fun MediaItem.Event.mediaInfo(): MediaInfo {
+ return when (this) {
+ is MediaItem.Image -> mediaInfo
+ is MediaItem.Video -> mediaInfo
+ is MediaItem.File -> mediaInfo
+ }
+}
+
+fun MediaItem.Event.mediaSource(): MediaSource {
+ return when (this) {
+ is MediaItem.Image -> mediaSource
+ is MediaItem.Video -> mediaSource
+ is MediaItem.File -> mediaSource
+ }
+}
+
+fun MediaItem.Event.thumbnailSource(): MediaSource? {
+ return when (this) {
+ is MediaItem.Image -> thumbnailSource
+ is MediaItem.Video -> thumbnailSource
+ is MediaItem.File -> null
+ }
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt
new file mode 100644
index 0000000000..6706dd08c8
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.mediaviewer.impl.gallery
+
+import io.element.android.libraries.architecture.AsyncData
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
+import javax.inject.Inject
+
+class MediaItemsPostProcessor @Inject constructor() {
+ fun process(
+ mediaItems: AsyncData>,
+ ): AsyncData {
+ return when (mediaItems) {
+ is AsyncData.Uninitialized -> AsyncData.Uninitialized
+ is AsyncData.Loading -> AsyncData.Loading()
+ is AsyncData.Failure -> AsyncData.Failure(mediaItems.error)
+ is AsyncData.Success -> AsyncData.Success(
+ mediaItems.data.process()
+ )
+ }
+ }
+
+ private fun List.process(): GroupedMediaItems {
+ val imageAndVideoItems = mutableListOf()
+ val fileItems = mutableListOf()
+
+ val imageAndVideoItemsSubList = mutableListOf()
+ val fileItemsSublist = mutableListOf()
+ forEach { item ->
+ when (item) {
+ is MediaItem.DateSeparator -> {
+ if (imageAndVideoItemsSubList.isNotEmpty()) {
+ // Date separator first
+ imageAndVideoItems.add(item)
+ // Then events
+ imageAndVideoItems.addAll(imageAndVideoItemsSubList)
+ imageAndVideoItemsSubList.clear()
+ }
+ if (fileItemsSublist.isNotEmpty()) {
+ // Date separator first
+ fileItems.add(item)
+ // Then events
+ fileItems.addAll(fileItemsSublist)
+ fileItemsSublist.clear()
+ }
+ }
+ is MediaItem.Event -> {
+ when (item) {
+ is MediaItem.Image,
+ is MediaItem.Video -> {
+ imageAndVideoItemsSubList.add(0, item)
+ }
+ is MediaItem.File -> {
+ fileItemsSublist.add(0, item)
+ }
+ }
+ }
+ is MediaItem.LoadingIndicator -> {
+ imageAndVideoItems.add(item)
+ fileItems.add(item)
+ }
+ }
+ }
+ if (imageAndVideoItemsSubList.isNotEmpty()) {
+ // Should not happen, since the SDK is always adding a date separator
+ imageAndVideoItems.addAll(imageAndVideoItemsSubList)
+ }
+ if (fileItemsSublist.isNotEmpty()) {
+ // Should not happen, since the SDK is always adding a date separator
+ fileItems.addAll(fileItemsSublist)
+ }
+ return GroupedMediaItems(
+ imageAndVideoItems = imageAndVideoItems.toImmutableList(),
+ fileItems = fileItems.toImmutableList(),
+ )
+ }
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaItemsFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaItemsFactory.kt
new file mode 100644
index 0000000000..79fcb8fd99
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaItemsFactory.kt
@@ -0,0 +1,86 @@
+/*
+ * 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 io.element.android.libraries.androidutils.diff.DefaultDiffCacheInvalidator
+import io.element.android.libraries.androidutils.diff.DiffCacheUpdater
+import io.element.android.libraries.androidutils.diff.MutableListDiffCache
+import io.element.android.libraries.core.coroutine.CoroutineDispatchers
+import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
+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 javax.inject.Inject
+
+class TimelineMediaItemsFactory @Inject constructor(
+ private val dispatchers: CoroutineDispatchers,
+ private val virtualItemFactory: VirtualItemFactory,
+ private val eventItemFactory: EventItemFactory,
+) {
+ private val _timelineItems = MutableSharedFlow>(replay = 1)
+ private val lock = Mutex()
+ private val diffCache = MutableListDiffCache()
+ private val diffCacheUpdater = DiffCacheUpdater(
+ diffCache = diffCache,
+ detectMoves = false,
+ cacheInvalidator = DefaultDiffCacheInvalidator()
+ ) { old, new ->
+ if (old is MatrixTimelineItem.Event && new is MatrixTimelineItem.Event) {
+ old.uniqueId == new.uniqueId
+ } else {
+ false
+ }
+ }
+
+ val timelineItems: Flow> = _timelineItems.distinctUntilChanged()
+
+ suspend fun replaceWith(
+ timelineItems: List,
+ ) = withContext(dispatchers.computation) {
+ lock.withLock {
+ diffCacheUpdater.updateWith(timelineItems)
+ buildAndEmitTimelineItemStates(timelineItems)
+ }
+ }
+
+ private suspend fun buildAndEmitTimelineItemStates(
+ timelineItems: List,
+ ) {
+ val newTimelineItemStates = ArrayList()
+ for (index in diffCache.indices().reversed()) {
+ val cacheItem = diffCache.get(index)
+ if (cacheItem == null) {
+ buildAndCacheItem(timelineItems, index)?.also { timelineItemState ->
+ newTimelineItemStates.add(timelineItemState)
+ }
+ } else {
+ newTimelineItemStates.add(cacheItem)
+ }
+ }
+ _timelineItems.emit(newTimelineItemStates.toPersistentList())
+ }
+
+ private fun buildAndCacheItem(
+ timelineItems: List,
+ index: Int,
+ ): MediaItem? {
+ val timelineItem =
+ when (val currentTimelineItem = timelineItems[index]) {
+ is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem)
+ is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem)
+ MatrixTimelineItem.Other -> null
+ }
+ diffCache[index] = timelineItem
+ return timelineItem
+ }
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/VirtualItemFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/VirtualItemFactory.kt
new file mode 100644
index 0000000000..22d5ef546b
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/VirtualItemFactory.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.dateformatter.api.DaySeparatorFormatter
+import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
+import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
+import javax.inject.Inject
+
+class VirtualItemFactory @Inject constructor(
+ private val daySeparatorFormatter: DaySeparatorFormatter,
+) {
+ fun create(timelineItem: MatrixTimelineItem.Virtual): MediaItem? {
+ return when (val virtual = timelineItem.virtual) {
+ is VirtualTimelineItem.DayDivider -> MediaItem.DateSeparator(
+ id = timelineItem.uniqueId,
+ formattedDate = daySeparatorFormatter.format(virtual.timestamp)
+ )
+ VirtualTimelineItem.LastForwardIndicator -> null
+ is VirtualTimelineItem.LoadingIndicator -> MediaItem.LoadingIndicator(
+ id = timelineItem.uniqueId,
+ direction = virtual.direction,
+ timestamp = virtual.timestamp
+ )
+ VirtualTimelineItem.ReadMarker -> null
+ VirtualTimelineItem.RoomBeginning -> null
+ VirtualTimelineItem.TypingNotification -> null
+ }
+ }
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryRootNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryRootNode.kt
new file mode 100644
index 0000000000..4f5272b01b
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryRootNode.kt
@@ -0,0 +1,139 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.mediaviewer.impl.gallery.root
+
+import android.os.Parcelable
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.core.plugin.plugins
+import com.bumble.appyx.navmodel.backstack.BackStack
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.libraries.architecture.BackstackWithOverlayBox
+import io.element.android.libraries.architecture.BaseFlowNode
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.architecture.overlay.Overlay
+import io.element.android.libraries.architecture.overlay.operation.hide
+import io.element.android.libraries.architecture.overlay.operation.show
+import io.element.android.libraries.di.RoomScope
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.media.MediaSource
+import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint
+import io.element.android.libraries.mediaviewer.api.MediaInfo
+import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
+import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryNode
+import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
+import io.element.android.libraries.mediaviewer.impl.gallery.eventId
+import io.element.android.libraries.mediaviewer.impl.gallery.mediaInfo
+import io.element.android.libraries.mediaviewer.impl.gallery.mediaSource
+import io.element.android.libraries.mediaviewer.impl.gallery.thumbnailSource
+import kotlinx.parcelize.Parcelize
+
+@ContributesNode(RoomScope::class)
+class MediaGalleryRootNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val mediaViewerEntryPoint: MediaViewerEntryPoint
+) : BaseFlowNode(
+ backstack = BackStack(
+ initialElement = NavTarget.Root,
+ savedStateMap = buildContext.savedStateMap,
+ ),
+ overlay = Overlay(
+ savedStateMap = buildContext.savedStateMap,
+ ),
+ buildContext = buildContext,
+ plugins = plugins,
+) {
+ sealed interface NavTarget : Parcelable {
+ @Parcelize
+ data object Root : NavTarget
+
+ @Parcelize
+ data class MediaViewer(
+ val eventId: EventId?,
+ val mediaInfo: MediaInfo,
+ val mediaSource: MediaSource,
+ val thumbnailSource: MediaSource?,
+ ) : NavTarget
+ }
+
+ private fun onBackClick() {
+ plugins().forEach {
+ it.onBackClick()
+ }
+ }
+
+ private fun onViewInTimeline(eventId: EventId) {
+ plugins().forEach {
+ it.onViewInTimeline(eventId)
+ }
+ }
+
+ override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
+ return when (navTarget) {
+ NavTarget.Root -> {
+ val callback = object : MediaGalleryNode.Callback {
+ override fun onBackClick() {
+ this@MediaGalleryRootNode.onBackClick()
+ }
+
+ override fun onViewInTimeline(eventId: EventId) {
+ this@MediaGalleryRootNode.onViewInTimeline(eventId)
+ }
+
+ override fun onItemClick(item: MediaItem.Event) {
+ overlay.show(
+ NavTarget.MediaViewer(
+ eventId = item.eventId(),
+ mediaInfo = item.mediaInfo(),
+ mediaSource = item.mediaSource(),
+ thumbnailSource = item.thumbnailSource(),
+ )
+ )
+ }
+ }
+ createNode(buildContext = buildContext, plugins = listOf(callback))
+ }
+ is NavTarget.MediaViewer -> {
+ val callback = object : MediaViewerEntryPoint.Callback {
+ override fun onDone() {
+ overlay.hide()
+ }
+
+ override fun onViewInTimeline(eventId: EventId) {
+ this@MediaGalleryRootNode.onViewInTimeline(eventId)
+ }
+ }
+ mediaViewerEntryPoint.nodeBuilder(this, buildContext)
+ .params(
+ MediaViewerEntryPoint.Params(
+ eventId = navTarget.eventId,
+ mediaInfo = navTarget.mediaInfo,
+ mediaSource = navTarget.mediaSource,
+ thumbnailSource = navTarget.thumbnailSource,
+ canShowInfo = true,
+ canDownload = true,
+ canShare = true,
+ )
+ )
+ .callback(callback)
+ .build()
+ }
+ }
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ BackstackWithOverlayBox(modifier)
+ }
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/DateItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/DateItemView.kt
new file mode 100644
index 0000000000..f0c44a382c
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/DateItemView.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.mediaviewer.impl.gallery.ui
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
+
+@Composable
+fun DateItemView(
+ item: MediaItem.DateSeparator,
+ modifier: Modifier = Modifier,
+) {
+ Text(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(12.dp),
+ text = item.formattedDate,
+ textAlign = TextAlign.Center,
+ style = ElementTheme.typography.fontBodyMdMedium,
+ color = ElementTheme.colors.textPrimary,
+ )
+}
+
+@PreviewsDayNight
+@Composable
+internal fun DateItemViewPreview(
+ @PreviewParameter(MediaItemDateSeparatorProvider::class) date: MediaItem.DateSeparator,
+) = ElementPreview {
+ DateItemView(date)
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/FileItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/FileItemView.kt
new file mode 100644
index 0000000000..99ff456296
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/FileItemView.kt
@@ -0,0 +1,183 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.mediaviewer.impl.gallery.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.libraries.core.extensions.withBrackets
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.IconButton
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
+
+@Composable
+fun FileItemView(
+ file: MediaItem.File,
+ onClick: () -> Unit,
+ onShareClick: () -> Unit,
+ onDownloadClick: () -> Unit,
+ onInfoClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(top = 20.dp, start = 16.dp, end = 16.dp),
+ ) {
+ FilenameRow(
+ file = file,
+ onClick = onClick,
+ )
+ val caption = file.mediaInfo.caption
+ if (caption != null) {
+ Spacer(modifier = Modifier.height(16.dp))
+ Caption(caption)
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ ActionIconsRow(
+ onShareClick = onShareClick,
+ onDownloadClick = onDownloadClick,
+ onInfoClick = onInfoClick,
+ )
+ HorizontalDivider()
+ }
+}
+
+@Composable
+private fun FilenameRow(
+ file: MediaItem.File,
+ onClick: () -> Unit,
+) {
+ Row(
+ modifier = Modifier
+ .clip(RoundedCornerShape(12.dp))
+ .background(
+ color = ElementTheme.colors.bgSubtleSecondary,
+ shape = RoundedCornerShape(12.dp),
+ )
+ .clickable { onClick() }
+ .fillMaxWidth()
+ .padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ modifier = Modifier
+ .background(
+ color = ElementTheme.colors.bgActionSecondaryRest,
+ shape = CircleShape,
+ )
+ .size(32.dp)
+ .padding(6.dp),
+ imageVector = CompoundIcons.Attachment(),
+ contentDescription = null,
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = file.mediaInfo.filename,
+ modifier = Modifier.weight(1f),
+ style = ElementTheme.typography.fontBodyLgRegular,
+ color = ElementTheme.colors.textPrimary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ val formattedSize = file.mediaInfo.formattedFileSize
+ if (formattedSize.isNotEmpty()) {
+ Text(
+ text = formattedSize.withBrackets(),
+ style = ElementTheme.typography.fontBodyLgRegular,
+ color = ElementTheme.colors.textPrimary,
+ )
+ }
+ }
+}
+
+@Composable
+private fun Caption(caption: String) {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = caption,
+ maxLines = 5,
+ overflow = TextOverflow.Ellipsis,
+ style = ElementTheme.typography.fontBodyLgRegular,
+ color = ElementTheme.colors.textPrimary,
+ )
+}
+
+@Composable
+private fun ActionIconsRow(
+ onShareClick: () -> Unit,
+ onDownloadClick: () -> Unit,
+ onInfoClick: () -> Unit,
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End
+ ) {
+ IconButton(
+ onClick = onShareClick,
+ ) {
+ Icon(
+ imageVector = CompoundIcons.ShareAndroid(),
+ contentDescription = null,
+ )
+ }
+ IconButton(
+ onClick = onDownloadClick,
+ ) {
+ Icon(
+ imageVector = CompoundIcons.Download(),
+ contentDescription = null,
+ )
+ }
+ IconButton(
+ onClick = onInfoClick,
+ ) {
+ Icon(
+ imageVector = CompoundIcons.Info(),
+ contentDescription = null,
+ )
+ }
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun FileItemViewPreview(
+ @PreviewParameter(MediaItemFileProvider::class) file: MediaItem.File,
+) = ElementPreview {
+ FileItemView(
+ file = file,
+ onClick = {},
+ onShareClick = {},
+ onDownloadClick = {},
+ onInfoClick = {},
+ )
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/ImageItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/ImageItemView.kt
new file mode 100644
index 0000000000..f92a29c1ae
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/ImageItemView.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.mediaviewer.impl.gallery.ui
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalInspectionMode
+import coil.compose.AsyncImage
+import coil.compose.AsyncImagePainter
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun ImageItemView(
+ image: MediaItem.Image,
+ onClick: () -> Unit,
+ onLongClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val bgColor = if (LocalInspectionMode.current) {
+ ElementTheme.colors.bgDecorative1
+ } else {
+ Color.Transparent
+ }
+ Box(
+ modifier = modifier
+ .aspectRatio(1f)
+ .combinedClickable(onClick = onClick, onLongClick = onLongClick)
+ .background(bgColor),
+ ) {
+ var isLoaded by remember { mutableStateOf(false) }
+ AsyncImage(
+ modifier = Modifier
+ .fillMaxWidth()
+ .then(if (isLoaded) Modifier.background(Color.White) else Modifier),
+ model = image.thumbnailMediaRequestData,
+ contentScale = ContentScale.Crop,
+ alignment = Alignment.Center,
+ contentDescription = null,
+ onState = { isLoaded = it is AsyncImagePainter.State.Success },
+ )
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun ImageItemViewPreview() = ElementPreview {
+ ImageItemView(
+ image = aMediaItemImage(),
+ onClick = {},
+ onLongClick = {},
+ )
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemDateSeparatorProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemDateSeparatorProvider.kt
new file mode 100644
index 0000000000..32169751f0
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemDateSeparatorProvider.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.mediaviewer.impl.gallery.ui
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.matrix.api.core.UniqueId
+import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
+
+class MediaItemDateSeparatorProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aMediaItemDateSeparator(),
+ aMediaItemDateSeparator(formattedDate = "A long date that should be truncated"),
+ )
+}
+
+fun aMediaItemDateSeparator(
+ id: UniqueId = UniqueId("dateId"),
+ formattedDate: String = "October 2024",
+): MediaItem.DateSeparator {
+ return MediaItem.DateSeparator(
+ id = id,
+ formattedDate = formattedDate,
+ )
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemFileProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemFileProvider.kt
new file mode 100644
index 0000000000..f5374cbbc2
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemFileProvider.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.mediaviewer.impl.gallery.ui
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.core.preview.loremIpsum
+import io.element.android.libraries.matrix.api.core.UniqueId
+import io.element.android.libraries.matrix.api.media.MediaSource
+import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
+import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
+
+class MediaItemFileProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aMediaItemFile(),
+ aMediaItemFile(
+ filename = "A long filename that should be truncated.jpg",
+ caption = "A caption",
+ ),
+ aMediaItemFile(
+ caption = loremIpsum,
+ ),
+ )
+}
+
+fun aMediaItemFile(
+ id: UniqueId = UniqueId("fileId"),
+ filename: String = "filename",
+ caption: String? = null,
+): MediaItem.File {
+ return MediaItem.File(
+ id = id,
+ eventId = null,
+ mediaInfo = aPdfMediaInfo(
+ filename = filename,
+ caption = caption,
+ ),
+ mediaSource = MediaSource(""),
+ )
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemImageProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemImageProvider.kt
new file mode 100644
index 0000000000..a422fc715b
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemImageProvider.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.mediaviewer.impl.gallery.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 aMediaItemImage(
+ id: UniqueId = UniqueId("imageId"),
+ eventId: EventId? = null,
+ senderId: UserId? = null,
+): MediaItem.Image {
+ return MediaItem.Image(
+ id = id,
+ eventId = eventId,
+ mediaInfo = anImageMediaInfo(
+ senderId = senderId,
+ ),
+ mediaSource = MediaSource(""),
+ thumbnailSource = null,
+ )
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemLoadingIndicatorProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemLoadingIndicatorProvider.kt
new file mode 100644
index 0000000000..d9323e5979
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemLoadingIndicatorProvider.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.mediaviewer.impl.gallery.ui
+
+import io.element.android.libraries.matrix.api.core.UniqueId
+import io.element.android.libraries.matrix.api.timeline.Timeline
+import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
+
+fun aMediaItemLoadingIndicator(
+ id: UniqueId = UniqueId("loadingId"),
+): MediaItem.LoadingIndicator {
+ return MediaItem.LoadingIndicator(
+ id = id,
+ direction = Timeline.PaginationDirection.BACKWARDS,
+ timestamp = 123,
+ )
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVideoProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVideoProvider.kt
new file mode 100644
index 0000000000..1cc223b347
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVideoProvider.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.mediaviewer.impl.gallery.ui
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.matrix.api.core.UniqueId
+import io.element.android.libraries.matrix.api.media.MediaSource
+import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
+import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
+
+class MediaItemVideoProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aMediaItemVideo(),
+ aMediaItemVideo(
+ duration = null,
+ ),
+ )
+}
+
+fun aMediaItemVideo(
+ id: UniqueId = UniqueId("videoId"),
+ mediaSource: MediaSource = MediaSource(""),
+ duration: String? = "1:23",
+): MediaItem.Video {
+ return MediaItem.Video(
+ id = id,
+ eventId = null,
+ mediaInfo = aVideoMediaInfo(),
+ mediaSource = mediaSource,
+ thumbnailSource = null,
+ duration = duration,
+ )
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt
new file mode 100644
index 0000000000..e6d67fb01a
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt
@@ -0,0 +1,116 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.mediaviewer.impl.gallery.ui
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalInspectionMode
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import coil.compose.AsyncImagePainter
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun VideoItemView(
+ video: MediaItem.Video,
+ onClick: () -> Unit,
+ onLongClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val bgColor = if (LocalInspectionMode.current) {
+ ElementTheme.colors.bgDecorative2
+ } else {
+ Color.Transparent
+ }
+ Box(
+ modifier = modifier
+ .aspectRatio(1f)
+ .combinedClickable(onClick = onClick, onLongClick = onLongClick)
+ .background(bgColor),
+ ) {
+ var isLoaded by remember { mutableStateOf(false) }
+ AsyncImage(
+ modifier = Modifier
+ .fillMaxWidth()
+ .then(if (isLoaded) Modifier.background(Color.White) else Modifier),
+ model = video.thumbnailMediaRequestData,
+ contentScale = ContentScale.Crop,
+ alignment = Alignment.Center,
+ contentDescription = null,
+ onState = { isLoaded = it is AsyncImagePainter.State.Success },
+ )
+ VideoInfoRow(
+ video = video,
+ modifier = Modifier.align(Alignment.BottomStart)
+ )
+ }
+}
+
+@Composable
+private fun VideoInfoRow(
+ video: MediaItem.Video,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ modifier = Modifier.size(20.dp),
+ imageVector = CompoundIcons.VideoCallSolid(),
+ contentDescription = null
+ )
+ if (video.duration != null) {
+ Spacer(Modifier.weight(1f))
+ Text(
+ text = video.duration,
+ style = ElementTheme.typography.fontBodySmMedium,
+ color = ElementTheme.colors.textPrimary,
+ )
+ }
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun VideoItemViewPreview(
+ @PreviewParameter(MediaItemVideoProvider::class) video: MediaItem.Video,
+) = ElementPreview {
+ VideoItemView(
+ video = video,
+ onClick = {},
+ onLongClick = {},
+ )
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt
index 8b5163cf6c..62706f120e 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt
@@ -18,6 +18,7 @@ import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
+import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaFile
import io.element.android.libraries.matrix.api.media.toFile
import io.element.android.libraries.mediaviewer.api.MediaInfo
@@ -41,7 +42,9 @@ class AndroidLocalMediaFactory @Inject constructor(
name = mediaInfo.filename,
caption = mediaInfo.caption,
formattedFileSize = mediaInfo.formattedFileSize,
+ senderId = mediaInfo.senderId,
senderName = mediaInfo.senderName,
+ senderAvatar = mediaInfo.senderAvatar,
dateSent = mediaInfo.dateSent,
)
@@ -56,7 +59,9 @@ class AndroidLocalMediaFactory @Inject constructor(
name = name,
caption = null,
formattedFileSize = formattedFileSize,
+ senderId = null,
senderName = null,
+ senderAvatar = null,
dateSent = null,
)
@@ -66,7 +71,9 @@ class AndroidLocalMediaFactory @Inject constructor(
name: String?,
caption: String?,
formattedFileSize: String?,
+ senderId: UserId?,
senderName: String?,
+ senderAvatar: String?,
dateSent: String?,
): LocalMedia {
val resolvedMimeType = mimeType ?: context.getMimeType(uri) ?: MimeTypes.OctetStream
@@ -81,7 +88,9 @@ class AndroidLocalMediaFactory @Inject constructor(
caption = caption,
formattedFileSize = fileSize,
fileExtension = fileExtension,
+ senderId = senderId,
senderName = senderName,
+ senderAvatar = senderAvatar,
dateSent = dateSent,
)
)
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt
index ac2714584c..6d9a31a816 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt
@@ -7,10 +7,17 @@
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 object OpenInfo : MediaViewerEvents
+ data class ConfirmDelete(val eventId: EventId) : MediaViewerEvents
+ data object CloseBottomSheet : MediaViewerEvents
+ data class Delete(val eventId: EventId) : MediaViewerEvents
}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNavigator.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNavigator.kt
new file mode 100644
index 0000000000..07fa0ec15d
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNavigator.kt
@@ -0,0 +1,15 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.mediaviewer.impl.viewer
+
+import io.element.android.libraries.matrix.api.core.EventId
+
+interface MediaViewerNavigator {
+ fun onViewInTimelineClick(eventId: EventId)
+ fun onItemDeleted()
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt
index 83c7c1aca7..9a9af5ee63 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt
@@ -19,14 +19,16 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.compound.theme.ForcedDarkElementTheme
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope
+import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
@ContributesNode(RoomScope::class)
-open class MediaViewerNode @AssistedInject constructor(
+class MediaViewerNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
presenterFactory: MediaViewerPresenter.Factory,
-) : Node(buildContext, plugins = plugins) {
+) : Node(buildContext, plugins = plugins),
+ MediaViewerNavigator {
private val inputs = inputs()
private fun onDone() {
@@ -35,7 +37,20 @@ open class MediaViewerNode @AssistedInject constructor(
}
}
- private val presenter = presenterFactory.create(inputs)
+ override fun onViewInTimelineClick(eventId: EventId) {
+ plugins().forEach {
+ it.onViewInTimeline(eventId)
+ }
+ }
+
+ override fun onItemDeleted() {
+ onDone()
+ }
+
+ private val presenter = presenterFactory.create(
+ inputs = inputs,
+ navigator = this,
+ )
@Composable
override fun View(modifier: Modifier) {
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt
index 068fb02b0f..a480d1ba7c 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt
@@ -25,11 +25,17 @@ 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
+import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope
@@ -38,6 +44,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 +53,10 @@ class MediaViewerPresenter @AssistedInject constructor(
) : Presenter {
@AssistedFactory
interface Factory {
- fun create(inputs: MediaViewerEntryPoint.Params): MediaViewerPresenter
+ fun create(
+ inputs: MediaViewerEntryPoint.Params,
+ navigator: MediaViewerNavigator,
+ ): MediaViewerPresenter
}
@Composable
@@ -66,6 +77,7 @@ class MediaViewerPresenter @AssistedInject constructor(
mediaFile.value?.close()
}
}
+ var mediaBottomSheetState by remember { mutableStateOf(MediaBottomSheetState.Hidden) }
fun handleEvents(mediaViewerEvents: MediaViewerEvents) {
when (mediaViewerEvents) {
@@ -74,16 +86,49 @@ 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 -> {
+ mediaBottomSheetState = MediaBottomSheetState.Hidden
+ coroutineScope.delete(mediaViewerEvents.eventId)
+ }
+ is MediaViewerEvents.ViewInTimeline -> {
+ mediaBottomSheetState = MediaBottomSheetState.Hidden
+ navigator.onViewInTimelineClick(mediaViewerEvents.eventId)
+ }
+ MediaViewerEvents.OpenInfo -> coroutineScope.launch {
+ mediaBottomSheetState = MediaBottomSheetState.MediaDetailsBottomSheetState(
+ eventId = inputs.eventId,
+ canDelete = when (inputs.mediaInfo.senderId) {
+ null -> false
+ room.sessionId -> room.canRedactOwn().getOrElse { false } && inputs.eventId != null
+ else -> room.canRedactOther().getOrElse { false } && inputs.eventId != null
+ },
+ mediaInfo = inputs.mediaInfo,
+ thumbnailSource = inputs.thumbnailSource,
+ )
+ }
+ is MediaViewerEvents.ConfirmDelete -> {
+ mediaBottomSheetState = MediaBottomSheetState.MediaDeleteConfirmationState(
+ eventId = mediaViewerEvents.eventId,
+ mediaInfo = inputs.mediaInfo,
+ thumbnailSource = inputs.thumbnailSource ?: inputs.mediaSource,
+ )
+ }
+ MediaViewerEvents.CloseBottomSheet -> {
+ mediaBottomSheetState = MediaBottomSheetState.Hidden
+ }
}
}
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,
+ mediaBottomSheetState = mediaBottomSheetState,
eventSink = ::handleEvents
)
}
@@ -126,6 +171,17 @@ class MediaViewerPresenter @AssistedInject constructor(
}
}
+ private fun CoroutineScope.delete(eventId: EventId) = launch {
+ room.liveTimeline.redactEvent(eventId.toEventOrTransactionId(), null)
+ .onFailure {
+ val snackbarMessage = SnackbarMessage(CommonStrings.error_unknown)
+ snackbarDispatcher.post(snackbarMessage)
+ }
+ .onSuccess {
+ navigator.onItemDeleted()
+ }
+ }
+
private fun CoroutineScope.share(localMedia: AsyncData) = launch {
if (localMedia is AsyncData.Success) {
localMediaActions.share(localMedia.data)
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt
index 94d6653241..6ae8554b06 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt
@@ -9,16 +9,21 @@ 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
+import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
data class MediaViewerState(
+ val eventId: EventId?,
val mediaInfo: MediaInfo,
val thumbnailSource: MediaSource?,
val downloadedMedia: AsyncData,
val snackbarMessage: SnackbarMessage?,
+ val canShowInfo: Boolean,
val canDownload: Boolean,
val canShare: Boolean,
+ val mediaBottomSheetState: MediaBottomSheetState,
val eventSink: (MediaViewerEvents) -> Unit,
)
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt
index 6c7a9fb704..8bed5dfc49 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt
@@ -17,6 +17,9 @@ import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
+import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
+import io.element.android.libraries.mediaviewer.impl.details.aMediaDeleteConfirmationState
+import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState
open class MediaViewerStateProvider : PreviewParameterProvider {
override val values: Sequence
@@ -30,10 +33,10 @@ open class MediaViewerStateProvider : PreviewParameterProvider
caption = "A caption",
).let {
aMediaViewerState(
- AsyncData.Success(
+ downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
- it,
+ mediaInfo = it,
)
},
aVideoMediaInfo(
@@ -42,69 +45,81 @@ open class MediaViewerStateProvider : PreviewParameterProvider
caption = "A caption",
).let {
aMediaViewerState(
- AsyncData.Success(
+ downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
- it,
+ mediaInfo = it,
)
},
aPdfMediaInfo().let {
aMediaViewerState(
- AsyncData.Success(
+ downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
- it,
+ mediaInfo = it,
)
},
aMediaViewerState(
- AsyncData.Loading(),
- anApkMediaInfo(),
+ downloadedMedia = AsyncData.Loading(),
+ mediaInfo = anApkMediaInfo(),
),
anApkMediaInfo().let {
aMediaViewerState(
- AsyncData.Success(
+ downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
- it,
+ mediaInfo = it,
)
},
aMediaViewerState(
- AsyncData.Loading(),
- anAudioMediaInfo(),
+ downloadedMedia = AsyncData.Loading(),
+ mediaInfo = anAudioMediaInfo(),
),
anAudioMediaInfo().let {
aMediaViewerState(
- AsyncData.Success(
+ downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
- it,
+ mediaInfo = it,
)
},
anImageMediaInfo().let {
aMediaViewerState(
- AsyncData.Success(
+ downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
- it,
+ mediaInfo = it,
+ canShowInfo = false,
canDownload = false,
canShare = false,
)
},
+ aMediaViewerState(
+ mediaBottomSheetState = aMediaDetailsBottomSheetState(),
+ ),
+ aMediaViewerState(
+ mediaBottomSheetState = aMediaDeleteConfirmationState(),
+ ),
)
}
fun aMediaViewerState(
downloadedMedia: AsyncData = AsyncData.Uninitialized,
mediaInfo: MediaInfo = anImageMediaInfo(),
+ canShowInfo: Boolean = true,
canDownload: Boolean = true,
canShare: Boolean = true,
+ mediaBottomSheetState: MediaBottomSheetState = MediaBottomSheetState.Hidden,
eventSink: (MediaViewerEvents) -> Unit = {},
) = MediaViewerState(
+ eventId = null,
mediaInfo = mediaInfo,
thumbnailSource = null,
downloadedMedia = downloadedMedia,
snackbarMessage = null,
+ canShowInfo = canShowInfo,
canDownload = canDownload,
canShare = canShare,
+ mediaBottomSheetState = mediaBottomSheetState,
eventSink = eventSink,
)
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt
index 3a468eb0f5..6ca9ebec9c 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt
@@ -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
@@ -121,7 +124,11 @@ fun MediaViewerView(
mimeType = state.mediaInfo.mimeType,
senderName = state.mediaInfo.senderName,
dateSent = state.mediaInfo.dateSent,
+ canShowInfo = state.canShowInfo,
onBackClick = onBackClick,
+ onInfoClick = {
+ state.eventSink(MediaViewerEvents.OpenInfo)
+ },
eventSink = state.eventSink
)
MediaViewerBottomBar(
@@ -133,6 +140,34 @@ fun MediaViewerView(
}
}
}
+ when (val bottomSheetState = state.mediaBottomSheetState) {
+ MediaBottomSheetState.Hidden -> Unit
+ is MediaBottomSheetState.MediaDetailsBottomSheetState -> {
+ MediaDetailsBottomSheet(
+ state = bottomSheetState,
+ onViewInTimeline = {
+ state.eventSink(MediaViewerEvents.ViewInTimeline(it))
+ },
+ onDelete = { eventId ->
+ state.eventSink(MediaViewerEvents.ConfirmDelete(eventId))
+ },
+ onDismiss = {
+ state.eventSink(MediaViewerEvents.CloseBottomSheet)
+ },
+ )
+ }
+ is MediaBottomSheetState.MediaDeleteConfirmationState -> {
+ MediaDeleteConfirmationBottomSheet(
+ state = bottomSheetState,
+ onDelete = {
+ state.eventSink(MediaViewerEvents.Delete(it))
+ },
+ onDismiss = {
+ state.eventSink(MediaViewerEvents.CloseBottomSheet)
+ },
+ )
+ }
+ }
}
@Composable
@@ -283,7 +318,9 @@ private fun MediaViewerTopBar(
mimeType: String,
senderName: String?,
dateSent: String?,
+ canShowInfo: Boolean,
onBackClick: () -> Unit,
+ onInfoClick: () -> Unit,
eventSink: (MediaViewerEvents) -> Unit,
) {
TopAppBar(
@@ -354,7 +391,17 @@ private fun MediaViewerTopBar(
)
}
}
- // TODO Add action to open infos.
+ if (canShowInfo) {
+ IconButton(
+ onClick = onInfoClick,
+ enabled = actionsEnabled,
+ ) {
+ Icon(
+ imageVector = CompoundIcons.Info(),
+ contentDescription = null,
+ )
+ }
+ }
}
)
}
diff --git a/libraries/mediaviewer/impl/src/main/res/values-cs/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-cs/translations.xml
new file mode 100644
index 0000000000..79f3b646ee
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/res/values-cs/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Obrázky a videa nahraná do této místnosti budou zobrazeny zde."
+ "Zatím nebyla nahrána žádná média"
+ "Načítání souborů…"
+ "Načítání médií…"
+ "Soubory"
+ "Média"
+ "Média a soubory"
+
diff --git a/libraries/mediaviewer/impl/src/main/res/values-de/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-de/translations.xml
new file mode 100644
index 0000000000..07438166f6
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/res/values-de/translations.xml
@@ -0,0 +1,14 @@
+
+
+ "In diesen Chatroom hochgeladene Bilder und Videos werden hier angezeigt."
+ "Noch keine Medien hochgeladen"
+ "Dateien werden geladen…"
+ "Medien werden geladen…"
+ "Dateien"
+ "Medien"
+ "Medien und Dateien"
+ "Dateiformat"
+ "Dateiname"
+ "Hochgeladen von"
+ "Hochgeladen am"
+
diff --git a/libraries/mediaviewer/impl/src/main/res/values-et/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-et/translations.xml
new file mode 100644
index 0000000000..396138c100
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/res/values-et/translations.xml
@@ -0,0 +1,16 @@
+
+
+ "Järgnevaga eemaldame selle faili jututoast ka tema liikmed enam ei pääse failile ligi."
+ "Kas kustutame faili?"
+ "Antud jututuppa üleslaaditud pildid ja videod kuvatakse siin."
+ "Mitte keegi pole veel meediat üles laadinud"
+ "Laadime faile…"
+ "Laadime meediat…"
+ "Failid"
+ "Meedia"
+ "Meedia ja failid"
+ "Failivorming"
+ "Failinimi"
+ "Üleslaadija"
+ "Üleslaaditud"
+
diff --git a/libraries/mediaviewer/impl/src/main/res/values-fr/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-fr/translations.xml
new file mode 100644
index 0000000000..bd961fb941
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/res/values-fr/translations.xml
@@ -0,0 +1,16 @@
+
+
+ "Ce fichier sera supprimé du salon et les membres n’y auront plus accès."
+ "Supprimer le fichier ?"
+ "Les images et vidéos envoyées dans ce salon seront affichées ici."
+ "Aucun média n’a encore été envoyé dans ce salon"
+ "Chargement des fichiers…"
+ "Chargement des médias…"
+ "Fichiers"
+ "Média"
+ "Médias et fichiers"
+ "Format du fichier"
+ "Nom du fichier"
+ "Envoyé par"
+ "Envoyé le"
+
diff --git a/libraries/mediaviewer/impl/src/main/res/values-hu/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-hu/translations.xml
new file mode 100644
index 0000000000..1fcc528dc5
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/res/values-hu/translations.xml
@@ -0,0 +1,16 @@
+
+
+ "Ez a fájl el lesz távolítva a szobából, és a tagok nem férhetnek hozzá."
+ "Törli a fájlt?"
+ "Az ebbe a szobába feltöltött képek és videók itt jelennek meg."
+ "Még nincs feltöltött média"
+ "Fájlok betöltése…"
+ "Média betöltése…"
+ "Fájlok"
+ "Média"
+ "Média és fájlok"
+ "Fájlformátum"
+ "Fájlnév"
+ "Feltöltötte:"
+ "Feltöltve:"
+
diff --git a/libraries/mediaviewer/impl/src/main/res/values-it/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-it/translations.xml
new file mode 100644
index 0000000000..45d160f3d2
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/res/values-it/translations.xml
@@ -0,0 +1,8 @@
+
+
+ "Le immagini e i video caricati in questa stanza verranno mostrati qui."
+ "Nessun file multimediale ancora caricato"
+ "File"
+ "Contenuti multimediali"
+ "File e contenuti multimediali"
+
diff --git a/libraries/mediaviewer/impl/src/main/res/values-ru/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-ru/translations.xml
new file mode 100644
index 0000000000..713b748617
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/res/values-ru/translations.xml
@@ -0,0 +1,16 @@
+
+
+ "Этот файл будет удален из комнаты и участники не будут иметь к нему доступ."
+ "Удалить файл?"
+ "Здесь будут показаны изображения и видео, загруженные в данную комнату."
+ "Пока что нет загруженных медиафайлов"
+ "Загрузка файлов…"
+ "Загрузка медиа…"
+ "Файлы"
+ "Медиа"
+ "Медиа и файлы"
+ "Формат файла"
+ "Имя файла"
+ "Загружено"
+ "Загружено на"
+
diff --git a/libraries/mediaviewer/impl/src/main/res/values/localazy.xml b/libraries/mediaviewer/impl/src/main/res/values/localazy.xml
new file mode 100644
index 0000000000..b35a4819f1
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/res/values/localazy.xml
@@ -0,0 +1,18 @@
+
+
+ "This file will be removed from the room and members won’t have access to it."
+ "Delete file?"
+ "Images and videos uploaded to this room will be shown here."
+ "No media uploaded yet"
+ "Loading files…"
+ "Loading media…"
+ "Files"
+ "Media"
+ "Media and files"
+ "File format"
+ "File name"
+ "This file will be removed from the room and members won’t have access to it."
+ "Delete file?"
+ "Uploaded by"
+ "Uploaded on"
+
diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/DefaultEventItemFactoryTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/DefaultEventItemFactoryTest.kt
new file mode 100644
index 0000000000..3dde8176b4
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/DefaultEventItemFactoryTest.kt
@@ -0,0 +1,413 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.mediaviewer.impl.gallery
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
+import io.element.android.libraries.core.mimetype.MimeTypes
+import io.element.android.libraries.dateformatter.test.A_FORMATTED_DATE
+import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
+import io.element.android.libraries.matrix.api.media.AudioDetails
+import io.element.android.libraries.matrix.api.media.AudioInfo
+import io.element.android.libraries.matrix.api.media.FileInfo
+import io.element.android.libraries.matrix.api.media.ImageInfo
+import io.element.android.libraries.matrix.api.media.MediaSource
+import io.element.android.libraries.matrix.api.media.VideoInfo
+import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
+import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent
+import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
+import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
+import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
+import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
+import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
+import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
+import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
+import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
+import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
+import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
+import io.element.android.libraries.matrix.test.AN_EVENT_ID
+import io.element.android.libraries.matrix.test.A_UNIQUE_ID
+import io.element.android.libraries.matrix.test.A_USER_ID
+import io.element.android.libraries.matrix.test.timeline.aMessageContent
+import io.element.android.libraries.matrix.test.timeline.aPollContent
+import io.element.android.libraries.matrix.test.timeline.aProfileChangeMessageContent
+import io.element.android.libraries.matrix.test.timeline.aStickerContent
+import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
+import io.element.android.libraries.mediaviewer.api.MediaInfo
+import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
+import kotlinx.collections.immutable.persistentListOf
+import org.junit.Test
+import kotlin.time.Duration.Companion.seconds
+
+class DefaultEventItemFactoryTest {
+ @Test
+ fun `create check all null cases`() {
+ val factory = createEventItemFactory()
+ val contents = listOf(
+ CallNotifyContent,
+ FailedToParseMessageLikeContent("", ""),
+ FailedToParseStateContent("", "", ""),
+ LegacyCallInviteContent,
+ aPollContent(),
+ aProfileChangeMessageContent(),
+ RedactedContent,
+ RoomMembershipContent(
+ userId = A_USER_ID,
+ userDisplayName = null,
+ change = null,
+ ),
+ StateContent("", OtherState.RoomCreate),
+ aStickerContent(
+ info = ImageInfo(
+ width = null,
+ height = null,
+ mimetype = null,
+ size = null,
+ thumbnailInfo = null,
+ thumbnailSource = null,
+ blurhash = null,
+ ),
+ mediaSource = MediaSource("")
+ ),
+ UnableToDecryptContent(UnableToDecryptContent.Data.Unknown),
+ UnknownContent,
+ )
+ contents.forEach {
+ val result = factory.create(
+ MatrixTimelineItem.Event(
+ uniqueId = A_UNIQUE_ID,
+ event = anEventTimelineItem(
+ content = it
+ )
+ )
+ )
+ assertThat(result).isNull()
+ }
+ }
+
+ @Test
+ fun `create MessageContent check all null cases`() {
+ val factory = createEventItemFactory()
+ val messageTypes = listOf(
+ EmoteMessageType("", null),
+ NoticeMessageType("", null),
+ OtherMessageType("", ""),
+ LocationMessageType("", "", null),
+ TextMessageType("", null)
+ )
+ messageTypes.forEach {
+ val result = factory.create(
+ MatrixTimelineItem.Event(
+ uniqueId = A_UNIQUE_ID,
+ event = anEventTimelineItem(
+ content = aMessageContent(
+ messageType = it
+ )
+ )
+ )
+ )
+ assertThat(result).isNull()
+ }
+ }
+
+ @Test
+ fun `create for FileMessageType`() {
+ val factory = createEventItemFactory()
+ val result = factory.create(
+ MatrixTimelineItem.Event(
+ uniqueId = A_UNIQUE_ID,
+ event = anEventTimelineItem(
+ content = aMessageContent(
+ messageType = FileMessageType(
+ filename = "filename.apk",
+ caption = "caption",
+ formattedCaption = null,
+ source = MediaSource(""),
+ info = FileInfo(
+ mimetype = MimeTypes.Apk,
+ size = 123L,
+ thumbnailInfo = null,
+ thumbnailSource = null,
+ )
+ )
+ )
+ )
+ )
+ )
+ assertThat(result).isEqualTo(
+ MediaItem.File(
+ id = A_UNIQUE_ID,
+ eventId = AN_EVENT_ID,
+ mediaInfo = MediaInfo(
+ mimeType = MimeTypes.Apk,
+ filename = "filename.apk",
+ caption = "caption",
+ formattedFileSize = "123 Bytes",
+ fileExtension = "apk",
+ senderId = A_USER_ID,
+ senderName = "alice",
+ senderAvatar = null,
+ dateSent = A_FORMATTED_DATE,
+ ),
+ mediaSource = MediaSource(""),
+ )
+ )
+ }
+
+ @Test
+ fun `create for ImageMessageType`() {
+ val factory = createEventItemFactory()
+ val result = factory.create(
+ MatrixTimelineItem.Event(
+ uniqueId = A_UNIQUE_ID,
+ event = anEventTimelineItem(
+ content = aMessageContent(
+ messageType = ImageMessageType(
+ filename = "filename.jpg",
+ caption = "caption",
+ formattedCaption = null,
+ source = MediaSource(""),
+ info = ImageInfo(
+ mimetype = MimeTypes.Jpeg,
+ size = 123L,
+ thumbnailInfo = null,
+ thumbnailSource = null,
+ height = 1L,
+ width = 2L,
+ blurhash = null,
+ )
+ )
+ )
+ )
+ )
+ )
+ assertThat(result).isEqualTo(
+ MediaItem.Image(
+ id = A_UNIQUE_ID,
+ eventId = AN_EVENT_ID,
+ mediaInfo = MediaInfo(
+ mimeType = MimeTypes.Jpeg,
+ filename = "filename.jpg",
+ caption = "caption",
+ formattedFileSize = "123 Bytes",
+ fileExtension = "jpg",
+ senderId = A_USER_ID,
+ senderName = "alice",
+ senderAvatar = null,
+ dateSent = A_FORMATTED_DATE,
+ ),
+ mediaSource = MediaSource(""),
+ thumbnailSource = null,
+ )
+ )
+ }
+
+ @Test
+ fun `create for AudioMessageType`() {
+ val factory = createEventItemFactory()
+ val result = factory.create(
+ MatrixTimelineItem.Event(
+ uniqueId = A_UNIQUE_ID,
+ event = anEventTimelineItem(
+ content = aMessageContent(
+ messageType = AudioMessageType(
+ filename = "filename.mp3",
+ caption = "caption",
+ formattedCaption = null,
+ source = MediaSource(""),
+ info = AudioInfo(
+ mimetype = MimeTypes.Mp3,
+ size = 123L,
+ duration = 456.seconds,
+ )
+ )
+ )
+ )
+ )
+ )
+ assertThat(result).isEqualTo(
+ MediaItem.File(
+ id = A_UNIQUE_ID,
+ eventId = AN_EVENT_ID,
+ mediaInfo = MediaInfo(
+ mimeType = MimeTypes.Mp3,
+ filename = "filename.mp3",
+ caption = "caption",
+ formattedFileSize = "123 Bytes",
+ fileExtension = "mp3",
+ senderId = A_USER_ID,
+ senderName = "alice",
+ senderAvatar = null,
+ dateSent = A_FORMATTED_DATE,
+ ),
+ mediaSource = MediaSource(""),
+ )
+ )
+ }
+
+ @Test
+ fun `create for VideoMessageType`() {
+ val factory = createEventItemFactory()
+ val result = factory.create(
+ MatrixTimelineItem.Event(
+ uniqueId = A_UNIQUE_ID,
+ event = anEventTimelineItem(
+ content = aMessageContent(
+ messageType = VideoMessageType(
+ filename = "filename.mp4",
+ caption = "caption",
+ formattedCaption = null,
+ source = MediaSource(""),
+ info = VideoInfo(
+ mimetype = MimeTypes.Mp4,
+ size = 123L,
+ thumbnailInfo = null,
+ duration = 123.seconds,
+ height = 1L,
+ width = 2L,
+ thumbnailSource = null,
+ blurhash = null
+ )
+ )
+ )
+ )
+ )
+ )
+ assertThat(result).isEqualTo(
+ MediaItem.Video(
+ id = A_UNIQUE_ID,
+ eventId = AN_EVENT_ID,
+ mediaInfo = MediaInfo(
+ mimeType = MimeTypes.Mp4,
+ filename = "filename.mp4",
+ caption = "caption",
+ formattedFileSize = "123 Bytes",
+ fileExtension = "mp4",
+ senderId = A_USER_ID,
+ senderName = "alice",
+ senderAvatar = null,
+ dateSent = A_FORMATTED_DATE,
+ ),
+ mediaSource = MediaSource(""),
+ thumbnailSource = null,
+ duration = "2:03",
+ )
+ )
+ }
+
+ @Test
+ fun `create for VoiceMessageType`() {
+ val factory = createEventItemFactory()
+ val result = factory.create(
+ MatrixTimelineItem.Event(
+ uniqueId = A_UNIQUE_ID,
+ event = anEventTimelineItem(
+ content = aMessageContent(
+ messageType = VoiceMessageType(
+ filename = "filename.ogg",
+ caption = "caption",
+ formattedCaption = null,
+ source = MediaSource(""),
+ info = AudioInfo(
+ mimetype = MimeTypes.Ogg,
+ size = 123L,
+ duration = 456.seconds,
+ ),
+ details = AudioDetails(
+ duration = 456.seconds,
+ waveform = persistentListOf(),
+ )
+ )
+ )
+ )
+ )
+ )
+ assertThat(result).isEqualTo(
+ MediaItem.File(
+ id = A_UNIQUE_ID,
+ eventId = AN_EVENT_ID,
+ mediaInfo = MediaInfo(
+ mimeType = MimeTypes.Ogg,
+ filename = "filename.ogg",
+ caption = "caption",
+ formattedFileSize = "123 Bytes",
+ fileExtension = "ogg",
+ senderId = A_USER_ID,
+ senderName = "alice",
+ senderAvatar = null,
+ dateSent = A_FORMATTED_DATE,
+ ),
+ mediaSource = MediaSource(""),
+ )
+ )
+ }
+
+ @Test
+ fun `create for StickerMessageType`() {
+ val factory = createEventItemFactory()
+ val result = factory.create(
+ MatrixTimelineItem.Event(
+ uniqueId = A_UNIQUE_ID,
+ event = anEventTimelineItem(
+ content = aMessageContent(
+ messageType = StickerMessageType(
+ filename = "filename.gif",
+ caption = "caption",
+ formattedCaption = null,
+ source = MediaSource(""),
+ info = ImageInfo(
+ mimetype = MimeTypes.Gif,
+ size = 123L,
+ thumbnailInfo = null,
+ thumbnailSource = null,
+ height = 1L,
+ width = 2L,
+ blurhash = null,
+ )
+ )
+ )
+ )
+ )
+ )
+ assertThat(result).isEqualTo(
+ MediaItem.Image(
+ id = A_UNIQUE_ID,
+ eventId = AN_EVENT_ID,
+ mediaInfo = MediaInfo(
+ mimeType = MimeTypes.Gif,
+ filename = "filename.gif",
+ caption = "caption",
+ formattedFileSize = "123 Bytes",
+ fileExtension = "gif",
+ senderId = A_USER_ID,
+ senderName = "alice",
+ senderAvatar = null,
+ dateSent = A_FORMATTED_DATE,
+ ),
+ mediaSource = MediaSource(""),
+ thumbnailSource = null,
+ )
+ )
+ }
+}
+
+private fun createEventItemFactory() = EventItemFactory(
+ fileSizeFormatter = FakeFileSizeFormatter(),
+ fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
+ lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(A_FORMATTED_DATE),
+)
diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaGalleryNavigator.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaGalleryNavigator.kt
new file mode 100644
index 0000000000..6633fcbce1
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaGalleryNavigator.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.mediaviewer.impl.gallery
+
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.tests.testutils.lambda.lambdaError
+
+class FakeMediaGalleryNavigator(
+ private val onViewInTimelineClickLambda: (EventId) -> Unit = { lambdaError() }
+) : MediaGalleryNavigator {
+ override fun onViewInTimelineClick(eventId: EventId) {
+ onViewInTimelineClickLambda(eventId)
+ }
+}
diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt
new file mode 100644
index 0000000000..4aeada8701
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt
@@ -0,0 +1,272 @@
+/*
+ * 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.libraries.androidutils.filesize.FakeFileSizeFormatter
+import io.element.android.libraries.dateformatter.test.A_FORMATTED_DATE
+import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
+import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
+import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.room.MatrixRoom
+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.aMediaItemImage
+import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions
+import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
+import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
+import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
+import io.element.android.tests.testutils.test
+import io.element.android.tests.testutils.testCoroutineDispatchers
+import io.mockk.mockk
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class MediaGalleryPresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ private val mockMediaUri: Uri = mockk("localMediaUri")
+ private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri)
+
+ @Test
+ fun `present - initial state`() = runTest {
+ val onViewInTimelineClickLambda = lambdaRecorder { }
+ val navigator = FakeMediaGalleryNavigator(
+ onViewInTimelineClickLambda = onViewInTimelineClickLambda,
+ )
+ val presenter = createMediaGalleryPresenter(
+ navigator = navigator,
+ room = FakeMatrixRoom(
+ displayName = A_ROOM_NAME,
+ mediaTimelineResult = { Result.success(FakeTimeline()) },
+ )
+ )
+ presenter.test {
+ skipItems(2)
+ val initialState = awaitItem()
+ assertThat(initialState.mode).isEqualTo(MediaGalleryMode.Images)
+ assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
+ assertThat(initialState.roomName).isEqualTo(A_ROOM_NAME)
+ assertThat(initialState.groupedMediaItems.dataOrNull()).isEqualTo(
+ GroupedMediaItems(
+ imageAndVideoItems = persistentListOf(),
+ fileItems = persistentListOf(),
+ )
+ )
+ assertThat(initialState.snackbarMessage).isNull()
+ }
+ }
+
+ @Test
+ fun `present - change mode`() = runTest {
+ val onViewInTimelineClickLambda = lambdaRecorder { }
+ val navigator = FakeMediaGalleryNavigator(
+ onViewInTimelineClickLambda = onViewInTimelineClickLambda,
+ )
+ val presenter = createMediaGalleryPresenter(
+ navigator = navigator,
+ room = FakeMatrixRoom(
+ displayName = A_ROOM_NAME,
+ mediaTimelineResult = { Result.success(FakeTimeline()) },
+ )
+ )
+ presenter.test {
+ skipItems(2)
+ val initialState = awaitItem()
+ assertThat(initialState.mode).isEqualTo(MediaGalleryMode.Images)
+ initialState.eventSink(MediaGalleryEvents.ChangeMode(MediaGalleryMode.Files))
+ val state = awaitItem()
+ assertThat(state.mode).isEqualTo(MediaGalleryMode.Files)
+ state.eventSink(MediaGalleryEvents.ChangeMode(MediaGalleryMode.Images))
+ val imageModeState = awaitItem()
+ assertThat(imageModeState.mode).isEqualTo(MediaGalleryMode.Images)
+ }
+ }
+
+ @Test
+ fun `present - bottom sheet state - own message and can delete own`() = runTest {
+ `present - bottom sheet state - own message`(canDeleteOwn = true)
+ }
+
+ @Test
+ fun `present - bottom sheet state - own message and cannot delete own`() = runTest {
+ `present - bottom sheet state - own message`(canDeleteOwn = false)
+ }
+
+ private suspend fun TestScope.`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 = aMediaItemImage(
+ 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 TestScope.`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 = aMediaItemImage(
+ 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 = aMediaItemImage()
+ initialState.eventSink(MediaGalleryEvents.ConfirmDelete(AN_EVENT_ID, item.mediaInfo, item.thumbnailSource))
+ val deleteState = awaitItem()
+ assertThat(deleteState.mediaBottomSheetState).isEqualTo(
+ MediaBottomSheetState.MediaDeleteConfirmationState(
+ eventId = AN_EVENT_ID,
+ mediaInfo = item.mediaInfo,
+ thumbnailSource = item.thumbnailSource,
+ )
+ )
+ // Close the bottom sheet
+ deleteState.eventSink(MediaGalleryEvents.CloseBottomSheet)
+ val deleteClosedState = awaitItem()
+ assertThat(deleteClosedState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
+ }
+ }
+
+ @Test
+ fun `present - view in timeline invokes the navigator`() = runTest {
+ val onViewInTimelineClickLambda = lambdaRecorder { }
+ val navigator = FakeMediaGalleryNavigator(
+ onViewInTimelineClickLambda = onViewInTimelineClickLambda,
+ )
+ val presenter = createMediaGalleryPresenter(
+ room = FakeMatrixRoom(
+ mediaTimelineResult = { Result.success(FakeTimeline()) },
+ ),
+ navigator = navigator,
+ )
+ presenter.test {
+ skipItems(2)
+ val initialState = awaitItem()
+ initialState.eventSink(MediaGalleryEvents.ViewInTimeline(AN_EVENT_ID))
+ onViewInTimelineClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
+ }
+ }
+
+ private fun TestScope.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,
+ timelineMediaItemsFactory = TimelineMediaItemsFactory(
+ dispatchers = testCoroutineDispatchers(),
+ virtualItemFactory = VirtualItemFactory(
+ daySeparatorFormatter = FakeDaySeparatorFormatter(),
+ ),
+ eventItemFactory = EventItemFactory(
+ fileSizeFormatter = FakeFileSizeFormatter(),
+ fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
+ lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(A_FORMATTED_DATE),
+ ),
+ ),
+ localMediaFactory = localMediaFactory,
+ mediaLoader = matrixMediaLoader,
+ localMediaActions = localMediaActions,
+ snackbarDispatcher = snackbarDispatcher,
+ mediaItemsPostProcessor = MediaItemsPostProcessor(),
+ )
+ }
+}
diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessorTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessorTest.kt
new file mode 100644
index 0000000000..75a911f1dc
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessorTest.kt
@@ -0,0 +1,210 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.mediaviewer.impl.gallery
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.matrix.api.core.UniqueId
+import io.element.android.libraries.matrix.test.AN_EXCEPTION
+import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemDateSeparator
+import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemFile
+import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage
+import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemLoadingIndicator
+import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemVideo
+import kotlinx.collections.immutable.toImmutableList
+import org.junit.Test
+
+class MediaItemsPostProcessorTest {
+ private val file1 = aMediaItemFile(id = UniqueId("1"))
+ private val file2 = aMediaItemFile(id = UniqueId("2"))
+ private val file3 = aMediaItemFile(id = UniqueId("3"))
+ private val image1 = aMediaItemImage(id = UniqueId("1"))
+ private val image2 = aMediaItemImage(id = UniqueId("2"))
+ private val image3 = aMediaItemImage(id = UniqueId("3"))
+ private val video1 = aMediaItemVideo(id = UniqueId("1"))
+ private val video2 = aMediaItemVideo(id = UniqueId("2"))
+ private val video3 = aMediaItemVideo(id = UniqueId("3"))
+ private val date1 = aMediaItemDateSeparator(id = UniqueId("1"))
+ private val date2 = aMediaItemDateSeparator(id = UniqueId("2"))
+ private val date3 = aMediaItemDateSeparator(id = UniqueId("3"))
+ private val loading1 = aMediaItemLoadingIndicator(id = UniqueId("1"))
+
+ @Test
+ fun `process Uninitialized`() {
+ val sut = MediaItemsPostProcessor()
+ val result = sut.process(AsyncData.Uninitialized)
+ assertThat(result).isEqualTo(AsyncData.Uninitialized)
+ }
+
+ @Test
+ fun `process Loading`() {
+ val sut = MediaItemsPostProcessor()
+ val result = sut.process(AsyncData.Loading())
+ assertThat(result).isEqualTo(AsyncData.Loading())
+ }
+
+ @Test
+ fun `process Failure`() {
+ val sut = MediaItemsPostProcessor()
+ val result = sut.process(AsyncData.Failure(AN_EXCEPTION))
+ assertThat(result).isEqualTo(AsyncData.Failure(AN_EXCEPTION))
+ }
+
+ @Test
+ fun `process Empty`() {
+ test(
+ mediaItems = listOf(),
+ expectedImageAndVideoItems = emptyList(),
+ expectedFileItems = emptyList(),
+ )
+ }
+
+ @Test
+ fun `process will reorder files`() {
+ test(
+ mediaItems = listOf(
+ file3,
+ file2,
+ file1,
+ date1,
+ ),
+ expectedImageAndVideoItems = emptyList(),
+ expectedFileItems = listOf(
+ date1,
+ file1,
+ file2,
+ file3,
+ ),
+ )
+ }
+
+ @Test
+ fun `process will reorder images`() {
+ test(
+ mediaItems = listOf(
+ image3,
+ image2,
+ image1,
+ date1,
+ ),
+ expectedImageAndVideoItems = listOf(
+ date1,
+ image1,
+ image2,
+ image3,
+ ),
+ expectedFileItems = emptyList(),
+ )
+ }
+
+ @Test
+ fun `process will split images, videos and files`() {
+ test(
+ mediaItems = listOf(
+ file1,
+ image1,
+ video1,
+ date1,
+ ),
+ expectedImageAndVideoItems = listOf(
+ date1,
+ video1,
+ image1,
+ ),
+ expectedFileItems = listOf(
+ date1,
+ file1,
+ ),
+ )
+ }
+
+ @Test
+ fun `process will skip date if there is no items`() {
+ test(
+ mediaItems = listOf(
+ date1,
+ date2,
+ date3,
+ ),
+ expectedImageAndVideoItems = emptyList(),
+ expectedFileItems = emptyList(),
+ )
+ }
+
+ @Test
+ fun `process will add the loading indicator to both list`() {
+ test(
+ mediaItems = listOf(
+ loading1,
+ ),
+ expectedImageAndVideoItems = listOf(
+ loading1,
+ ),
+ expectedFileItems = listOf(
+ loading1,
+ ),
+ )
+ }
+
+ @Test
+ fun `process will handle complex case`() {
+ test(
+ mediaItems = listOf(
+ file1,
+ image1,
+ video1,
+ date1,
+ file3,
+ date3,
+ video3,
+ video2,
+ date2,
+ loading1,
+ ),
+ expectedImageAndVideoItems = listOf(
+ date1,
+ video1,
+ image1,
+ date2,
+ video2,
+ video3,
+ loading1,
+ ),
+ expectedFileItems = listOf(
+ date1,
+ file1,
+ date3,
+ file3,
+ loading1,
+ ),
+ )
+ }
+
+ private fun test(
+ mediaItems: List,
+ expectedImageAndVideoItems: List,
+ expectedFileItems: List,
+ ) {
+ val sut = MediaItemsPostProcessor()
+ val result = sut.process(AsyncData.Success(mediaItems.toImmutableList()))
+ val data = result.dataOrNull()!!
+
+ // Compare the lists to have better failure info
+ assertThat(data.imageAndVideoItems.toList()).isEqualTo(expectedImageAndVideoItems)
+ assertThat(data.fileItems.toList()).isEqualTo(expectedFileItems)
+
+ assertThat(result).isEqualTo(
+ AsyncData.Success(
+ GroupedMediaItems(
+ imageAndVideoItems = expectedImageAndVideoItems.toImmutableList(),
+ fileItems = expectedFileItems.toImmutableList(),
+ )
+ )
+ )
+ }
+}
diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt
index c341f6e751..f60c43572e 100644
--- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt
+++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt
@@ -11,6 +11,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.MediaFile
+import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.media.FakeMediaFile
import io.element.android.libraries.mediaviewer.api.MediaInfo
@@ -27,6 +28,7 @@ class AndroidLocalMediaFactoryTest {
fun `test AndroidLocalMediaFactory`() {
val sut = createAndroidLocalMediaFactory()
val result = sut.createFromMediaFile(aMediaFile(), anImageMediaInfo(
+ senderId = A_USER_ID,
senderName = A_USER_NAME,
dateSent = "12:34",
))
@@ -38,7 +40,9 @@ class AndroidLocalMediaFactoryTest {
mimeType = MimeTypes.Jpeg,
formattedFileSize = "4MB",
fileExtension = "jpg",
+ senderId = A_USER_ID,
senderName = A_USER_NAME,
+ senderAvatar = null,
dateSent = "12:34"
)
)
diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/FakeMediaViewerNavigator.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/FakeMediaViewerNavigator.kt
new file mode 100644
index 0000000000..c07c53f8ae
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/FakeMediaViewerNavigator.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.mediaviewer.impl.viewer
+
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.tests.testutils.lambda.lambdaError
+
+class FakeMediaViewerNavigator(
+ private val onViewInTimelineClickLambda: (EventId) -> Unit = { lambdaError() },
+ private val onItemDeletedLambda: () -> Unit = { lambdaError() },
+) : MediaViewerNavigator {
+ override fun onViewInTimelineClick(eventId: EventId) {
+ onViewInTimelineClickLambda(eventId)
+ }
+
+ override fun onItemDeleted() {
+ onItemDeletedLambda()
+ }
+}
diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt
index cbe334216c..43835e71e5 100644
--- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt
+++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt
@@ -16,20 +16,35 @@ 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.impl.details.MediaBottomSheetState
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
import io.element.android.tests.testutils.WarmUpRule
+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 +53,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.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
+ }
+ }
+
+ @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.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
+ }
+ }
+
+ @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.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
+ }
+ }
+
+ @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.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
+ }
+ }
+
+ @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.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
+ }
+ }
+
+ @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.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
+ }
+ }
+
@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 +197,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 +250,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 +280,90 @@ class MediaViewerPresenterTest {
}
}
+ @Test
+ fun `present - delete media success scenario`() = runTest {
+ val redactEventLambda = lambdaRecorder> { _, _ ->
+ Result.success(Unit)
+ }
+ val timeline = FakeTimeline().apply {
+ this.redactEventLambda = redactEventLambda
+ }
+ val onItemDeletedLambda = lambdaRecorder { }
+ val navigator = FakeMediaViewerNavigator(
+ onItemDeletedLambda = onItemDeletedLambda,
+ )
+
+ val presenter = createMediaViewerPresenter(
+ room = FakeMatrixRoom(
+ liveTimeline = timeline,
+ canRedactOwnResult = { Result.success(true) },
+ ),
+ mediaViewerNavigator = navigator,
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized)
+ assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
+ val loadingState = awaitItem()
+ assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
+ val successState = awaitItem()
+ assertThat(successState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
+ successState.eventSink(MediaViewerEvents.Delete(AN_EVENT_ID))
+ redactEventLambda.assertions()
+ .isCalledOnce()
+ .with(
+ value(AN_EVENT_ID.toEventOrTransactionId()),
+ value(null),
+ )
+ onItemDeletedLambda.assertions().isCalledOnce()
+ }
+ }
+
+ @Test
+ fun `present - view in timeline invokes the navigator`() = runTest {
+ val onViewInTimelineClickLambda = lambdaRecorder { }
+ val navigator = FakeMediaViewerNavigator(
+ onViewInTimelineClickLambda = onViewInTimelineClickLambda,
+ )
+ val presenter = createMediaViewerPresenter(
+ mediaViewerNavigator = navigator,
+ room = FakeMatrixRoom(
+ canRedactOwnResult = { Result.success(true) },
+ )
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized)
+ assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
+ val loadingState = awaitItem()
+ assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
+ val successState = awaitItem()
+ assertThat(successState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
+ successState.eventSink(MediaViewerEvents.ViewInTimeline(AN_EVENT_ID))
+ onViewInTimelineClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
+ }
+ }
+
private fun createMediaViewerPresenter(
- matrixMediaLoader: FakeMatrixMediaLoader,
- localMediaActions: FakeLocalMediaActions,
+ eventId: EventId? = null,
+ matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(),
+ localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
+ canShowInfo: Boolean = true,
canShare: Boolean = true,
canDownload: Boolean = true,
+ mediaViewerNavigator: MediaViewerNavigator = FakeMediaViewerNavigator(),
+ room: MatrixRoom = FakeMatrixRoom(
+ liveTimeline = FakeTimeline(),
+ ),
): MediaViewerPresenter {
return MediaViewerPresenter(
inputs = MediaViewerEntryPoint.Params(
+ eventId = eventId,
mediaInfo = TESTED_MEDIA_INFO,
mediaSource = aMediaSource(),
thumbnailSource = null,
+ canShowInfo = canShowInfo,
canShare = canShare,
canDownload = canDownload,
),
@@ -153,6 +371,8 @@ class MediaViewerPresenterTest {
mediaLoader = matrixMediaLoader,
localMediaActions = localMediaActions,
snackbarDispatcher = snackbarDispatcher,
+ navigator = mediaViewerNavigator,
+ room = room,
)
}
}
diff --git a/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeLocalMediaFactory.kt b/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeLocalMediaFactory.kt
index a0f36c6f0f..c41435afc0 100644
--- a/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeLocalMediaFactory.kt
+++ b/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeLocalMediaFactory.kt
@@ -37,7 +37,9 @@ class FakeLocalMediaFactory(
mimeType = mimeType ?: fallbackMimeType,
formattedFileSize = formattedFileSize ?: fallbackFileSize,
fileExtension = fileExtensionExtractor.extractFromName(safeName),
+ senderId = null,
senderName = null,
+ senderAvatar = null,
dateSent = null
)
return aLocalMedia(uri, mediaInfo)
diff --git a/libraries/ui-strings/src/main/res/values-cs/translations.xml b/libraries/ui-strings/src/main/res/values-cs/translations.xml
index f1b88a1c76..9479eb43c4 100644
--- a/libraries/ui-strings/src/main/res/values-cs/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-cs/translations.xml
@@ -303,13 +303,6 @@ Důvod: %1$s."
"Ahoj, ozvi se mi na %1$s: %2$s"
"%1$s Android"
"Zatřeste zařízením pro nahlášení chyby"
- "Obrázky a videa nahraná do této místnosti budou zobrazeny zde."
- "Zatím nebyla nahrána žádná média"
- "Načítání souborů…"
- "Načítání médií…"
- "Soubory"
- "Média"
- "Média a soubory"
"Výběr média se nezdařil, zkuste to prosím znovu."
"Titulky nemusí být viditelné pro lidi, kteří používají starší aplikace."
"Nahrání média se nezdařilo, zkuste to prosím znovu."
@@ -334,19 +327,10 @@ Důvod: %1$s."
"Vaše zpráva nebyla odeslána, protože jste neověřili jedno nebo více zařízení"
"Nahrání média se nezdařilo, zkuste to prosím znovu."
"Nepodařilo se načíst údaje o uživateli"
-
- - "%1$s +%2$d další chce vstoupit do této místnosti"
- - "%1$s +%2$d další chtějí vstoupit do této místnosti"
- - "%1$s +%2$d dalších chce vstoupit do této místnosti"
-
- "Zobrazit vše"
"%1$s z %2$s"
"%1$s Připnuté zprávy"
"Načítání zprávy…"
"Zobrazit vše"
- "Přijmout"
- "%1$s chce vstoupit do této místnosti"
- "Zobrazit"
"Chat"
"Žádost o vstup odeslána"
"Sdílet polohu"
diff --git a/libraries/ui-strings/src/main/res/values-de/translations.xml b/libraries/ui-strings/src/main/res/values-de/translations.xml
index 4f2ca9bf97..0d7acd7e90 100644
--- a/libraries/ui-strings/src/main/res/values-de/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-de/translations.xml
@@ -299,17 +299,6 @@ Grund: %1$s."
"Hey, sprich mit mir auf %1$s: %2$s"
"%1$s Android"
"Schüttel heftig zum Melden von Fehlern"
- "In diesen Chatroom hochgeladene Bilder und Videos werden hier angezeigt."
- "Noch keine Medien hochgeladen"
- "Dateien werden geladen…"
- "Medien werden geladen…"
- "Dateien"
- "Medien"
- "Medien und Dateien"
- "Dateiformat"
- "Dateiname"
- "Hochgeladen von"
- "Hochgeladen am"
"Medienauswahl fehlgeschlagen, bitte versuche es erneut."
"Bildunterschriften sind für Nutzer älterer Apps möglicherweise nicht sichtbar."
"Fehler beim Verarbeiten des hochgeladenen Mediums. Bitte versuche es erneut."
@@ -333,18 +322,10 @@ Grund: %1$s."
"Ihre Nachricht wurde nicht geschickt, da Sie eines oder mehrere Ihrer Geräte nicht verifiziert haben."
"Fehler beim Verarbeiten des hochgeladenen Mediums. Bitte versuche es erneut."
"Benutzerdetails konnten nicht abgerufen werden"
-
- - "%1$s+ %2$d andere wollen diesem Chatroom beitreten"
- - "%1$s+ %2$d andere wollen diesem Chatroom beitreten"
-
- "Alles ansehen"
"%1$s von %2$s"
"%1$s fixierte Nachrichten"
"Nachricht wird geladen…"
"Alle anzeigen"
- "Akzeptieren"
- "%1$s möchte diesem Chatroom beitreten"
- "Ansicht"
"Chat"
"Beitrittsanfrage gesendet"
"Standort teilen"
diff --git a/libraries/ui-strings/src/main/res/values-el/translations.xml b/libraries/ui-strings/src/main/res/values-el/translations.xml
index 89e96e3f3c..c05000f3a5 100644
--- a/libraries/ui-strings/src/main/res/values-el/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-el/translations.xml
@@ -322,18 +322,10 @@
"Το μήνυμά σου δεν στάλθηκε επειδή δεν έχεις επαληθεύσει τουλάχιστον μία από τις συσκευές σου"
"Αποτυχία μεταφόρτωσης μέσου, δοκίμασε ξανά."
"Δεν ήταν δυνατή η ανάκτηση στοιχείων χρήστη"
-
- - "Οι χρήστες %1$s +%2$d ακόμη θέλουν να συμμετάσχουν σε αυτό το δωμάτιο"
- - "Οι χρήστες %1$s +%2$d ακόμη θέλουν να συμμετάσχουν σε αυτό το δωμάτιο"
-
- "Προβολή όλων"
"%1$s από %2$s"
"%1$s Καρφιτσωμένα μηνύματα"
"Φόρτωση μηνύματος…"
"Προβολή Όλων"
- "Αποδοχή"
- "Ο χρήστης %1$s θέλει να μπει σε αυτό το δωμάτιο"
- "Προβολή"
"Συνομιλία"
"Το αίτημα συμμετοχής στάλθηκε"
"Κοινή χρήση τοποθεσίας"
diff --git a/libraries/ui-strings/src/main/res/values-et/translations.xml b/libraries/ui-strings/src/main/res/values-et/translations.xml
index b09e10b8b3..0d7f51baef 100644
--- a/libraries/ui-strings/src/main/res/values-et/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-et/translations.xml
@@ -299,13 +299,6 @@ Põhjus: %1$s."
"Hei, suhtle minuga %1$s võrgus: %2$s"
"%1$s Android"
"Veast teatamiseks raputa nutiseadet ägedalt"
- "Antud jututuppa üleslaaditud pildid ja videod kuvatakse siin."
- "Mitte keegi pole veel meediat üles laadinud"
- "Laadime faile…"
- "Laadime meediat…"
- "Failid"
- "Meedia"
- "Meedia ja failid"
"Meediafaili valimine ei õnnestunud. Palun proovi uuesti."
"Selgitused ja alapealkirjad ei pruugi olla nähtavad vanemate rakenduste kasutajatele."
"Meediafaili töötlemine enne üleslaadimist ei õnnestunud. Palun proovi uuesti."
@@ -329,18 +322,10 @@ Põhjus: %1$s."
"Kuna sul on üks või enam verifitseerimata seadet, siis sinu sõnum jäi saatmata"
"Meediafaili töötlemine enne üleslaadimist ei õnnestunud. Palun proovi uuesti."
"Kasutaja andmete laadimine ei õnnestunud"
-
- - "%1$s + veel %2$d kasutaja soovivad selle jututoaga liituda"
- - "%1$s + veel %2$d kasutajat soovivad selle jututoaga liituda"
-
- "Vaata kõiki"
"%1$s / %2$s"
"%1$s esiletõstetud sõnumit"
"Laadime sõnumit…"
"Näita kõiki"
- "Nõustu"
- "%1$s soovib selle jututoaga liituda"
- "Vaata"
"Vestlus"
"Liitumispäring on saadetud"
"Jaga asukohta"
diff --git a/libraries/ui-strings/src/main/res/values-fr/translations.xml b/libraries/ui-strings/src/main/res/values-fr/translations.xml
index ac4bff851e..ecf513c269 100644
--- a/libraries/ui-strings/src/main/res/values-fr/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-fr/translations.xml
@@ -299,19 +299,6 @@ Raison : %1$s."
"Salut, parle-moi sur %1$s : %2$s"
"%1$s Android"
"Rageshake pour signaler un problème"
- "Ce fichier sera supprimé du salon et les membres n’y auront plus accès."
- "Supprimer le fichier ?"
- "Les images et vidéos envoyées dans ce salon seront affichées ici."
- "Aucun média n’a encore été envoyé dans ce salon"
- "Chargement des fichiers…"
- "Chargement des médias…"
- "Fichiers"
- "Média"
- "Médias et fichiers"
- "Format du fichier"
- "Nom du fichier"
- "Envoyé par"
- "Envoyé le"
"Échec de la sélection du média, veuillez réessayer."
"Les légendes peuvent ne pas être visibles pour les utilisateurs d’anciennes applications."
"Échec du traitement des médias à télécharger, veuillez réessayer."
@@ -335,18 +322,10 @@ Raison : %1$s."
"Votre message n’a pas été envoyé car vous n’avez pas vérifié tous vos appareils"
"Échec du traitement des médias à télécharger, veuillez réessayer."
"Impossible de récupérer les détails de l’utilisateur"
-
- - "%1$s et %2$d autre personne souhaitent rejoindre ce salon"
- - "%1$s et %2$d autres personnes souhaitent rejoindre ce salon"
-
- "Tout afficher"
"%1$s sur %2$s"
"%1$s Messages épinglés"
"Chargement du message…"
"Voir tout"
- "Accepter"
- "%1$s souhaite rejoindre ce salon"
- "Voir"
"Discussion"
"Demande d’adhésion envoyée"
"Partage de position"
diff --git a/libraries/ui-strings/src/main/res/values-hu/translations.xml b/libraries/ui-strings/src/main/res/values-hu/translations.xml
index b30c6f6896..0f1bb7507f 100644
--- a/libraries/ui-strings/src/main/res/values-hu/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-hu/translations.xml
@@ -299,19 +299,6 @@ Ok: %1$s."
"Beszélgessünk itt: %1$s, %2$s"
"%1$s Android"
"Az eszköz rázása a hibajelentéshez"
- "Ez a fájl el lesz távolítva a szobából, és a tagok nem férhetnek hozzá."
- "Törli a fájlt?"
- "Az ebbe a szobába feltöltött képek és videók itt jelennek meg."
- "Még nincs feltöltött média"
- "Fájlok betöltése…"
- "Média betöltése…"
- "Fájlok"
- "Média"
- "Média és fájlok"
- "Fájlformátum"
- "Fájlnév"
- "Feltöltötte:"
- "Feltöltve:"
"Nem sikerült kiválasztani a médiát, próbálja újra."
"Előfordulhat, hogy a feliratok nem láthatók a régebbi alkalmazásokat használók számára."
"Nem sikerült feldolgozni a feltöltendő médiát, próbálja újra."
@@ -335,18 +322,10 @@ Ok: %1$s."
"Az üzenet nem lett elküldve, mert egy vagy több eszközét nem ellenőrizte"
"Nem sikerült feldolgozni a feltöltendő médiát, próbálja újra."
"Nem sikerült letölteni a felhasználói adatokat"
-
- - "%1$s és még %2$d felhasználó szeretne csatlakozni ehhez a szobához"
- - "%1$s és még %2$d felhasználó szeretne csatlakozni ehhez a szobához"
-
- "Összes megtekintése"
"%1$s / %2$s"
"%1$s kitűzött üzenet"
"Üzenet betöltése…"
"Összes megtekintése"
- "Elfogadás"
- "%1$s szeretne csatlakozni ehhez a szobához"
- "Megtekintés"
"Csevegés"
"Csatlakozási kérés elküldve"
"Hely megosztása"
diff --git a/libraries/ui-strings/src/main/res/values-it/translations.xml b/libraries/ui-strings/src/main/res/values-it/translations.xml
index 69bfd8cc95..68547a7f7e 100644
--- a/libraries/ui-strings/src/main/res/values-it/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-it/translations.xml
@@ -299,11 +299,6 @@ Motivo:. %1$s"
"Ehi, parliamo su %1$s: %2$s"
"%1$s Android"
"Scuoti per segnalare un problema"
- "Le immagini e i video caricati in questa stanza verranno mostrati qui."
- "Nessun file multimediale ancora caricato"
- "File"
- "Contenuti multimediali"
- "File e contenuti multimediali"
"Selezione del file multimediale fallita, riprova."
"Le didascalie potrebbero non essere visibili agli utenti di app meno recenti."
"Elaborazione del file multimediale da caricare fallita, riprova."
@@ -327,18 +322,10 @@ Motivo:. %1$s"
"Il tuo messaggio non è stato inviato perché non hai verificato uno o più dispositivi."
"Elaborazione del file multimediale da caricare fallita, riprova."
"Impossibile recuperare i dettagli dell\'utente"
-
- - "%1$s +%2$d vogliono entrare in questa stanza"
- - "%1$s +%2$d vogliono entrare in questa stanza"
-
- "Visualizza tutte"
"%1$s di %2$s"
"%1$s Messaggi fissati"
"Caricamento messaggio…"
"Mostra tutti"
- "Accetta"
- "%1$s vuole entrare in questa stanza"
- "Visualizza"
"Conversazione"
"Richiesta di accesso inviata"
"Condividi posizione"
diff --git a/libraries/ui-strings/src/main/res/values-ru/translations.xml b/libraries/ui-strings/src/main/res/values-ru/translations.xml
index 7bb6675bd5..b2a05f452c 100644
--- a/libraries/ui-strings/src/main/res/values-ru/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-ru/translations.xml
@@ -303,13 +303,6 @@
"Привет, поговори со мной по %1$s: %2$s"
"%1$s Android"
"Встряхните устройство, чтобы сообщить об ошибке"
- "Здесь будут показаны изображения и видео, загруженные в данную комнату."
- "Пока что нет загруженных медиафайлов"
- "Загрузка файлов…"
- "Загрузка медиа…"
- "Файлы"
- "Медиа"
- "Медиа и файлы"
"Не удалось выбрать носитель, попробуйте еще раз."
"Подпись может быть не видна пользователям старых приложений."
"Не удалось обработать медиафайл для загрузки, попробуйте еще раз."
@@ -334,19 +327,10 @@
"Ваше сообщение не было отправлено, поскольку вы не подтвердили одно или несколько своих устройств."
"Не удалось обработать медиафайл для загрузки, попробуйте еще раз."
"Не удалось получить данные о пользователе"
-
- - "%1$s +%2$d хочет присоединиться к этой комнате"
- - "%1$s +%2$d хотят присоединиться к этой комнате"
- - "%1$s +%2$d хотят присоединиться к этой комнате"
-
- "Показать все"
"%1$s из %2$s"
"%1$s Закрепленные сообщения"
"Загрузка сообщения…"
"Посмотреть все"
- "Принять"
- "%1$s хочет присоединиться к этой комнате"
- "Просмотр"
"Чат"
"Запрос на присоединение отправлен"
"Поделиться местоположением"
diff --git a/libraries/ui-strings/src/main/res/values-sk/translations.xml b/libraries/ui-strings/src/main/res/values-sk/translations.xml
index c43567ea44..1c26d0eaae 100644
--- a/libraries/ui-strings/src/main/res/values-sk/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-sk/translations.xml
@@ -323,19 +323,10 @@ Dôvod: %1$s."
"Vaša správa nebola odoslaná, pretože ste neoverili jedno alebo viac svojich zariadení"
"Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova."
"Nepodarilo sa získať údaje o používateľovi"
-
- - "%1$s +%2$d ďalší chcú vstúpiť do tejto miestnosti"
- - "%1$s +%2$d ďalší chcú vstúpiť do tejto miestnosti"
- - "%1$s +%2$d ďalších chce vstúpiť do tejto miestnosti"
-
- "Zobraziť všetko"
"%1$s z %2$s"
"%1$s Pripnutých správ"
"Načítava sa správa…"
"Zobraziť všetko"
- "Prijať"
- "%1$s chce vstúpiť do tejto miestnosti"
- "Zobraziť"
"Konverzácia"
"Žiadosť o vstup odoslaná"
"Zdieľať polohu"
diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml
index 27be174f00..b571a23230 100644
--- a/libraries/ui-strings/src/main/res/values/localazy.xml
+++ b/libraries/ui-strings/src/main/res/values/localazy.xml
@@ -148,6 +148,7 @@
"Device ID"
"Direct chat"
"Do not show this again"
+ "Downloading"
"(edited)"
"Editing"
"Editing caption"
@@ -299,19 +300,6 @@ Reason: %1$s."
"Hey, talk to me on %1$s: %2$s"
"%1$s Android"
"Rageshake to report bug"
- "This file will be removed from the room and members won’t have access to it."
- "Delete file?"
- "Images and videos uploaded to this room will be shown here."
- "No media uploaded yet"
- "Loading files…"
- "Loading media…"
- "Files"
- "Media"
- "Media and files"
- "File format"
- "File name"
- "Uploaded by"
- "Uploaded on"
"Failed selecting media, please try again."
"Captions might not be visible to people using older apps."
"Failed processing media to upload, please try again."
@@ -355,6 +343,7 @@ Reason: %1$s."
"en"
"en"
"Historical messages are not available on this device"
+ "You don\'t have access to this message"
"Unable to decrypt message"
"This message was blocked either because you did not verify your device or because the sender needs to verify your identity."
diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_0_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_0_en.png
index 76446e6180..12b3132118 100644
--- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_0_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_0_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:94f040a3d18493f80b5f90eb48e68c664de5ddee0ae4575905ce35709d31abe9
-size 40969
+oid sha256:c7828106cd3724769c5bbeaad50c3417264abcb6af40ffd90aee283e8b29e579
+size 41831
diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_10_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_10_en.png
index 52912aa0fd..58d7c45b3d 100644
--- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_10_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_10_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:96cf72bdceae29a86593ed3bd02d5edbe1f5422e5be0798f536b49805088b0b8
-size 45109
+oid sha256:f9c24abc59ed8ef26f647b5d3855b768af6043bdbb035f0d2756a7b783f64561
+size 39848
diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_11_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_11_en.png
index 0ae9c7dc0e..e2e7745abc 100644
--- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_11_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_11_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:0c719ba2c0782ecf8ebf37c07dbc79d37b1d993e4987388ceafbefb31b03d100
-size 44064
+oid sha256:b87d165479dd2a0d6497fdac37bb43de760fd0eade06ad23b53baff667b8af03
+size 38783
diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_12_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_12_en.png
index fba01a25c6..09080780f9 100644
--- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_12_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_12_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:a675afee3fcef0f8468ec93e33e1e86398bda517f4f54615aaf527d549387431
-size 47217
+oid sha256:2e7726872c78a2bcddfefa689699d7bf69a09b55c023bbc7008575fbec5b7779
+size 41986
diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_13_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_13_en.png
index b3273a0efd..62a3a99fba 100644
--- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_13_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_13_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:ced35352da8f7b6c9d4a5647cf1ff29f194d4f68ca9eec9c268ab889271e4776
-size 45507
+oid sha256:6235b9e9b6ffb9e4d813cfa49c9819a3cbc112d6dc5d25d7901a257e4353e609
+size 40241
diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_1_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_1_en.png
index 4a0208786f..4f70fd407b 100644
--- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_1_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_1_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:3444cc70e1f1b212d89ba404199a439a498281aa9faff9a9bb2469b727498224
-size 37486
+oid sha256:75bcd07324f5acb45552d8d5bbe369cece798d109f3c096859c6a88fccc8e2e1
+size 39797
diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_2_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_2_en.png
index 16b2995961..5591d4b251 100644
--- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_2_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_2_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:afcf1a235cd16b501ec02f7da90cf4800df41ab07383b7e6ed502f4e9249855e
-size 38354
+oid sha256:f69da1fef91846329a0835dfc2be82b1f49892193dadf139f6ac262355aff86e
+size 39910
diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_3_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_3_en.png
index cedbb0f72d..406387611a 100644
--- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_3_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_3_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:5338fba98c85142b4300467a564e8920627ba83ec91dffb7e2370d07447b8d78
-size 38479
+oid sha256:2ddb13a08e77486addc983662966128492074b5a2ddc4afb193ba459c8366952
+size 40544
diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_4_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_4_en.png
index f7c16b996f..ac33cc9543 100644
--- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_4_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_4_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:9c7887b5f1cc07ef30ef347c149af51edbc1c4539615c04fef57737839677423
-size 44293
+oid sha256:003386cc6af1fb6c3d52724e234627021a131f6eaf4c5261f58bfbba9d87bb54
+size 40981
diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_5_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_5_en.png
index 1774b7ba41..15c85d775e 100644
--- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_5_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_5_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:11c9af054eb293134755003d8864305d9f2ef9a7597df795e4354e4f7c420166
-size 42209
+oid sha256:9f50132f14d4f26077cbb44e616b4b76070fd6df20e14af740e2743d1fc874b8
+size 40020
diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_6_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_6_en.png
index 8b0e9ee674..55ca265ccf 100644
--- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_6_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_6_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:d3bd2e90f06f259b158b6438ff4cd045733362ad215b933ddbb855043a5b8fa2
-size 38463
+oid sha256:76f09daad900e1c3eac91d07ec81c6a714f80ae9a734e3a3921dc5b259bab278
+size 40502
diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_7_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_7_en.png
index 52789eb061..4bc9febcfd 100644
--- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_7_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_7_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:ae95b22ef977a95f9de6dba9e8ed2b33ebca808db4437512be39f020fd8831da
-size 46411
+oid sha256:0b2d15ec833e6e20fe302d377c11f686275d5d115dbba070d623c46f66f95f95
+size 41111
diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_8_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_8_en.png
index aa849d237d..4aaa0ce01a 100644
--- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_8_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_8_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:2724c07c5ce097c2c470ecb4168fe2d101fb56a2b37a88065aa9210b14b871be
-size 45403
+oid sha256:0f8a39cfe4c761462b84425e1008a5c36c8f93a6d00e7e6f2ece108575bf6b89
+size 40124
diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_9_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_9_en.png
index c4aca87a18..3a7de20375 100644
--- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_9_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetailsDark_9_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:df340c45b1d0a07f53adcc2872aef1a691c4fe4d0280e961524281a3dc1e427b
-size 45412
+oid sha256:959b422fefa73279a7a3ef2193c7cd5c597fbedbaddea7de245cc86b820c2514
+size 40158
diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_0_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_0_en.png
index a6694758f1..8e8c5d5fcd 100644
--- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_0_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_0_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:bee1a47e22df24ba29b46dac1c8c2da8c38b3d438f8ef5b72dc3c39b0900338a
-size 41908
+oid sha256:da5e1b0b8dc2663baf0e491878868f43c078d6be0d81d4776dacbd28f34d794c
+size 42866
diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_10_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_10_en.png
index c09b745e24..11c6879648 100644
--- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_10_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_10_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:655995891afd39283b5271280d594d6b2ca0e3ff004e81ebbc4e351be0cd185e
-size 46007
+oid sha256:0837ae930f448d0cf0f0614c437ead15398cafa5a96aa50e0967cf297d3ff355
+size 40662
diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_11_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_11_en.png
index c3ddd6dd92..2485aea5d3 100644
--- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_11_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_11_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:ff42edd6e8f1165bfe8ad24ad2e1a37a34138b30193283ceb070e09273c37247
-size 44976
+oid sha256:72b557a35f41804729912f653ff64a70c9173aeff63471367bf4f5e88bd8e7b8
+size 39664
diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_12_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_12_en.png
index ea86ba1b2e..c6b95729a7 100644
--- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_12_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_12_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:a21c4945fa617d0bdd5549c98a0b18067f302cb71e7e012728a61120a6ba7269
-size 47772
+oid sha256:684bae4170ee939c3317880889fa28e4c467ddf704a13280ca9a1e33d9ac7776
+size 42526
diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_13_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_13_en.png
index b4d73c57b7..72c9a2497c 100644
--- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_13_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_13_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:dc76558ad62b1d9ec77afd066b2c64edc7241b93aaa431d8545041f7024315b1
-size 46443
+oid sha256:2c96854d4054cec6a4e3f2f642def4abc500f9a22dfe1aadd24cf45eb326a815
+size 41109
diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_1_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_1_en.png
index fec1bbe806..34156265b9 100644
--- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_1_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_1_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:ab627c807db2e5bcf339f01fd0f11f5e79529b32165d96f2a192faa863c38dd4
-size 38380
+oid sha256:f47468b9b9dc5c7c91b2d0b1446fd3f6e45ea9ee5b760029421a28e52410db77
+size 40935
diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_2_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_2_en.png
index 7f4fb4df94..17e997b919 100644
--- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_2_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_2_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:af7f4944f2e1bd57e1a02716ff02a67beeb8a05847a0d06387fa0a8ca5ec0481
-size 39221
+oid sha256:0ff7f7f25df81af089c0c22bb7937f01ea1d05e7acc09de1fd8c355f03c329c7
+size 41035
diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_3_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_3_en.png
index fe253a9a6e..cdf150fbc0 100644
--- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_3_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_3_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:647a6e7c0fdbb3d89aa411c14a2f41b127dd597fa157f4ef37a909132368c47e
-size 39114
+oid sha256:b6259c338f58959fc732676553418f8b6dfd5e50cd3b55ceac75b5d95a9feda5
+size 41323
diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_4_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_4_en.png
index d0bb2ef17c..5c7a431cbd 100644
--- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_4_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_4_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:c0ea6b1bf786b06fbe4ad211f9dbda7094f30f5089f7bb186d4024f064612785
-size 45210
+oid sha256:2fde798527f42790e6fe230dc896481223db0481605f551e5f7e840a3a78566b
+size 42028
diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_5_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_5_en.png
index 045e6fbe0a..686029b8da 100644
--- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_5_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_5_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:7cf9b0b10c112a964bbc85e6c14b634d96460884992c53e8ae9408ec5a94455a
-size 43073
+oid sha256:1d133a0d749da6a68605594ce69494fb8bb8094023a80e617872a40789bd4213
+size 40891
diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_6_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_6_en.png
index 8a5a3a51ae..48df048115 100644
--- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_6_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_6_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:1aa79cc35f4f4e9f6221b1f6cf119906bef17cc62268fae01ff1ec713931b7b9
-size 39619
+oid sha256:fbecca870bf0bedc0db8195b222b75408e8a2846424cf9de9fcf86e7342734d9
+size 41750
diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_7_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_7_en.png
index 8656744f17..12816f97f3 100644
--- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_7_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_7_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:526a16419357ee26e460511190d95c4f9adf8686d8a688704634025298b5153e
-size 47451
+oid sha256:3c1002160e799e3fb8c122f030f76dc29b0a766e533a9fe2151c9244777293f8
+size 42181
diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_8_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_8_en.png
index 34883fce29..3e61505a0e 100644
--- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_8_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_8_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:fe2e1f53003df2f9fd33e90861221a4adec4e4104ddf1162502b70a895b798eb
-size 46410
+oid sha256:139702b064abb7b4b2d862e6f05730e2d3cc631cde28bde6c2a0770c5e08dbd1
+size 41072
diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_9_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_9_en.png
index 5fa82cc6ce..d4bea56772 100644
--- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_9_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl_RoomDetails_9_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:44e97087d7fefeb63beb81ef0e52ea6616820bf325217f75aa0a11806f6c4313
-size 46366
+oid sha256:a9c44b7df1330d879fb78c3cde3e4664a470fec1508183f274cb5c572d457993
+size 41033
diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_84_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_84_en.png
new file mode 100644
index 0000000000..d78e8bad2a
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_84_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:938fb1c1ade57ac6421387d9ea5142448842a32d7ce446d4743d1aa15b2944d9
+size 15284
diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_85_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_85_en.png
new file mode 100644
index 0000000000..deb69c6aa1
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_85_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a7619c6bc9d0cb6ea7660992ed16ce24eafe10052f9a8a6e7708f7bc5c079fdc
+size 14541
diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_86_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_86_en.png
new file mode 100644
index 0000000000..bd51d8c202
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_86_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4bef7c3d043454a4faf858456c7fcc98d1a17a0846a891af3332e76c4e10b553
+size 17102
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDeleteConfirmationBottomSheet_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDeleteConfirmationBottomSheet_Day_0_en.png
new file mode 100644
index 0000000000..137a7d8afe
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDeleteConfirmationBottomSheet_Day_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8070f1089c8d151b74558046ca70d3c92525d80109dcc082ac05be5678b7b6e0
+size 31230
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDeleteConfirmationBottomSheet_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDeleteConfirmationBottomSheet_Night_0_en.png
new file mode 100644
index 0000000000..7a2ce52d92
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDeleteConfirmationBottomSheet_Night_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2dc3ac99e6894376a01f3f9cdbe8efcfd43233ada646944634489620535d326e
+size 29603
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDetailsBottomSheet_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDetailsBottomSheet_Day_0_en.png
new file mode 100644
index 0000000000..55e944524e
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDetailsBottomSheet_Day_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4f3557280a4010e7ffdb6f22c11561573780c0b24c27e264073d3a3899169014
+size 29659
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDetailsBottomSheet_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDetailsBottomSheet_Night_0_en.png
new file mode 100644
index 0000000000..33c24c3341
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.details_MediaDetailsBottomSheet_Night_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:63efb9744640b71cfde672821c7c1ea8b33f9c3c2e2ad4d5f41149b9749b31c2
+size 28016
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_DateItemView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_DateItemView_Day_0_en.png
new file mode 100644
index 0000000000..fd7b7e6443
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_DateItemView_Day_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:467d501edd69dc1cc2ee57b2558ed6215f0464495d09a421692c2e30d4dc0fb3
+size 6473
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_DateItemView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_DateItemView_Day_1_en.png
new file mode 100644
index 0000000000..f835ed50a3
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_DateItemView_Day_1_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b4a741963a984c6f491290fda0f399a73a92099cbef9a6c79676a1f89bbc53b1
+size 9050
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_DateItemView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_DateItemView_Night_0_en.png
new file mode 100644
index 0000000000..a0e5cbaec3
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_DateItemView_Night_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:45592dde62aa3f5272bb63df450b5eb76f634caadbabc0ac416c27882edb2ecc
+size 6340
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_DateItemView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_DateItemView_Night_1_en.png
new file mode 100644
index 0000000000..8430ef926c
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_DateItemView_Night_1_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c5298e5212daba4deda2bb931f9de1af660af559dc77f092622ded925125233e
+size 8898
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Day_0_en.png
new file mode 100644
index 0000000000..2d5f0a7613
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Day_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:421bd0bd141a6f6b38ce938c68cb21672b1d3c9cbb83e8cd44fe47bbc2488c3f
+size 11168
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Day_1_en.png
new file mode 100644
index 0000000000..fc5c101c2e
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Day_1_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c94c521f50f3b8d2fc5e03365e54e8582ee48d31a47146abfb34cc41972fd38b
+size 15539
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Day_2_en.png
new file mode 100644
index 0000000000..e28f8c7a7c
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Day_2_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3a8d98c3b6bd629ca6318fb7ac8d989960bf2bb952a9719a305568864d3bbb77
+size 38554
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Night_0_en.png
new file mode 100644
index 0000000000..e73f79b22d
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Night_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:00f111de4ffa63cfb0cc047ea017a2e740f52a9d0786fadf379937c12ae0e199
+size 10715
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Night_1_en.png
new file mode 100644
index 0000000000..5663da541d
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Night_1_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2637ec2e1303341c473cb9380c18fb7c7c1558c1dbd39dcff8d71744e4c3f5c9
+size 14901
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Night_2_en.png
new file mode 100644
index 0000000000..84aae36b0a
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_FileItemView_Night_2_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:57b058e64c8361a70492056a09d13e87c4e1b61fbfe785198282d035d27326ac
+size 37041
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_ImageItemView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_ImageItemView_Day_0_en.png
new file mode 100644
index 0000000000..a027c89303
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_ImageItemView_Day_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:15d3fa6a95cda6bca06ad79d3f4862db05e38111cdcac47c1cdd3aa204bc1f97
+size 4210
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_ImageItemView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_ImageItemView_Night_0_en.png
new file mode 100644
index 0000000000..503f2bb229
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_ImageItemView_Night_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:abaae9e0c6bf9d7dec701e9a51592e89408668e0a2b8325731efdfdc73978acd
+size 3667
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_VideoItemView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_VideoItemView_Day_0_en.png
new file mode 100644
index 0000000000..f5caf4274b
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_VideoItemView_Day_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:70f93330adb987d6f98d654670ab0898957d765ad3d92a47c9a1c781b24f9059
+size 5317
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_VideoItemView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_VideoItemView_Day_1_en.png
new file mode 100644
index 0000000000..1821a79941
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_VideoItemView_Day_1_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3ffac5911f928411fa0ac94e9ac59f6b8bb8bce1016e06f348d946f9d10053e5
+size 4539
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_VideoItemView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_VideoItemView_Night_0_en.png
new file mode 100644
index 0000000000..43d55840d9
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_VideoItemView_Night_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e0ee87589ec0e4f7cf67775dfa69cd289aeb27f22087bd91d54102923a28557d
+size 4775
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_VideoItemView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_VideoItemView_Night_1_en.png
new file mode 100644
index 0000000000..f743d18269
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery.ui_VideoItemView_Night_1_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6504e09eb09a9e28bc70594e669ec87abd290b4fe20e2ee9b3588c2116a049cd
+size 3994
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_0_en.png
new file mode 100644
index 0000000000..14e6352729
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:29d55f260237060e5ac280d6c87f5eefbdaa9dc6710572cab38fbd41dea77090
+size 15465
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_10_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_10_en.png
new file mode 100644
index 0000000000..ef0ab7b7ee
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_10_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:925df53e0666d800b0f047f45abda50f1744ef1571594be50efd6008ed988b72
+size 14549
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_1_en.png
new file mode 100644
index 0000000000..14e6352729
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_1_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:29d55f260237060e5ac280d6c87f5eefbdaa9dc6710572cab38fbd41dea77090
+size 15465
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_2_en.png
new file mode 100644
index 0000000000..14e65ac8bd
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_2_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:08b47d8b631032d7cb33cdd150eab67e4dc9e6498813e0f5107007c30143d249
+size 26058
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_3_en.png
new file mode 100644
index 0000000000..9a840d69b9
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_3_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a6c04486280ce42c65486d4c532c20521f0d03419f1ab042dc61c0b5ff1060c2
+size 18242
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_4_en.png
new file mode 100644
index 0000000000..5544b7f86f
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_4_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:88c68143c2bc7b098664d881bb6c0f2c35ac10ec690c0c7eb7ae6d9366144e83
+size 15136
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_5_en.png
new file mode 100644
index 0000000000..5544b7f86f
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_5_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:88c68143c2bc7b098664d881bb6c0f2c35ac10ec690c0c7eb7ae6d9366144e83
+size 15136
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_6_en.png
new file mode 100644
index 0000000000..18144ddccb
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_6_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1b7055ce0a8214e7c445c2d1b7d30ad5ffc63617c9f9f837661dfbd057198681
+size 26092
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_7_en.png
new file mode 100644
index 0000000000..7a192f89f4
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_7_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ce8fec32ec1a902edb12d9580ca73d0cc5a9838f91d31dbbf8329326b9fa1a68
+size 39676
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_8_en.png
new file mode 100644
index 0000000000..03b462a778
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_8_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:983e505a211c92ae92e091f9ba7cc43a655d5f3ce6d6bcf70971d43984507326
+size 36153
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_9_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_9_en.png
new file mode 100644
index 0000000000..90c8032c3e
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_9_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:fff7687206e1b1c03ecc4da231e8d030ec730573070550f1d6a7c355ba0c90d2
+size 14525
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_0_en.png
new file mode 100644
index 0000000000..a8e5914937
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_0_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e379008ac3a5346b727bd9355a4a59b75a1a2eca8855ad4ef5b6d7c239da129b
+size 15076
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_10_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_10_en.png
new file mode 100644
index 0000000000..2ffaf9289e
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_10_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d3fe3f3e7668a3d529132172366622db970391dcc8718f87a7fc90ec67b93ed1
+size 13992
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_1_en.png
new file mode 100644
index 0000000000..a8e5914937
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_1_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e379008ac3a5346b727bd9355a4a59b75a1a2eca8855ad4ef5b6d7c239da129b
+size 15076
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_2_en.png
new file mode 100644
index 0000000000..47b5c76148
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_2_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a99c2ae96a72551e0da2e6e3cce08a76fffc2f99763c81d5c3fe65a9604bbdc7
+size 25564
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_3_en.png
new file mode 100644
index 0000000000..7a9b4af687
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_3_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7b332b50ff448deea9bb3fc0db047b99743bb16c730a9ef3dbb203b7ca8982dc
+size 17067
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_4_en.png
new file mode 100644
index 0000000000..30b2d63dd7
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_4_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d414cb2e4268815943ce6d78fe8d85f576e055bd8ff4f652b7e15a664e3d8cb3
+size 14597
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_5_en.png
new file mode 100644
index 0000000000..30b2d63dd7
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_5_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d414cb2e4268815943ce6d78fe8d85f576e055bd8ff4f652b7e15a664e3d8cb3
+size 14597
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_6_en.png
new file mode 100644
index 0000000000..aca9e44958
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_6_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4f07b50b154c426638c38897fca296f5d52fb0054ad459eadf8fbe344c9b0526
+size 25475
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_7_en.png
new file mode 100644
index 0000000000..41f7c7f5fd
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_7_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:946183ccbaf14471579f3b48861715d04ed45d6352d36126a419de7e6f362bfd
+size 37632
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_8_en.png
new file mode 100644
index 0000000000..85ef965bae
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_8_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4e7a43195bd617ee952ea084e6b17a7e08e8b3634e8f3ce7df6e98f067ab08dc
+size 34296
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_9_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_9_en.png
new file mode 100644
index 0000000000..506cb4f837
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_9_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:bd4903a8a383c5fe3c8c5bfeaf26ab16d3c785fccaab5b66de8b31f3380e9272
+size 14104
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_0_en.png
index 93393fba05..7214efd2c9 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_0_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_0_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:f8672f053256e9ab2984aa06a22e4c27f2dd3f3373f3d87c12bc3269297713fd
-size 389602
+oid sha256:043da4a779363f5162b1fbb6b1159ab3ae3f6a1635473146a5b73242525b5e53
+size 390373
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_11_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_11_en.png
new file mode 100644
index 0000000000..99618a6ea0
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_11_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8a6916d2441316cb3ef55ee4c6a3b3ba9134d246d72c27aa3871408a6b9e59fc
+size 30716
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_12_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_12_en.png
new file mode 100644
index 0000000000..70f2f64c13
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_12_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:514f67acd325e01d010466786eb85e7db8071c36e2f21454be8f30c4a6a57425
+size 32356
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_1_en.png
index 4439239760..1ad4e9e788 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_1_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_1_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:33f460febda376d35c6eceb643dca374e9dbc76d30f21ccab99085a628e90e79
-size 389629
+oid sha256:a2c00187eb25b297f7debb7424969e5535e48366d139b7f073b6a7628f155d60
+size 390395
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_2_en.png
index 061278ca3f..9511942ab8 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_2_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_2_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:1b5c823df53d7a6fb2df235c2016b0e71bbf26aa2f7ec1c175e3360424b69c37
-size 94822
+oid sha256:94520145bde5de6a0d7820dd44d7f0243d9fb06eca062b19b72cb2457abdfb7d
+size 95438
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_3_en.png
index 5d77b5705d..475bdd1991 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_3_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_3_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:4b5506ab9651470ae3fe08d89a1ee2783d333a0fc1a657e45d50c156d664e84c
-size 396617
+oid sha256:82710704b6daff1c1e12a4a3c782f68744c0d6b3ee30b3fa1e38739176c6eaef
+size 397724
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_4_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_4_en.png
index dcbe73cf41..685149c94d 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_4_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_4_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:e4a44ab880d3d9660c6671505d0575f6a89e21c4e4a16d5838b6826fe770473d
-size 22041
+oid sha256:1547883dfc7ae3d742d2da2f6e5c6c2d6182d6039b3ce6075dbbe1d24f4ba341
+size 23370
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_5_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_5_en.png
index c8856654b4..57c533a361 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_5_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_5_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:1c7122bd07a25e38d8056ca2f9d297e2073c418d359b4d286a5ba3441bce1e4b
-size 5712
+oid sha256:66add7cb3b696075b7e931b06d1d8472cfddc2fc1902081864c3d88754f7404a
+size 6702
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_6_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_6_en.png
index fbda655334..2142adf1d5 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_6_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_6_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:f67a1db4ce45d9b5d2cf8f9f3bf0e5e6de439d1d9631a407826f984afdaf90c7
-size 14756
+oid sha256:a86ec4a40a63f646e62b1c6a3e481f0b68be442923e39b6750836ae4e5ac3045
+size 15577
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_7_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_7_en.png
index 4ff875eb5c..8f3f77a739 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_7_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_7_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:3b9d8de8cddf75a662c72d519eef8ae6409e109d944826147f71ddc97f2d5a40
-size 14954
+oid sha256:0abf1fdae34ea898f817f9667a02adb551d5e3bb528f73b4c2c01826f4ca375a
+size 15881
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_8_en.png
index 1d9b20bcf3..54fda061bc 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_8_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_8_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:7e202c4748e6c0afcfdcfb77c3e3f14a234d0622e579ea926311e669486ba0e8
-size 13576
+oid sha256:c786dc4f2ec41fc334cd67febc105670c999da57cec1ea263daff2358ff5c766
+size 14406
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_9_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_9_en.png
index 9698527a7d..8e2a70f3ce 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_9_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_9_en.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:646a010fc57669e8bd7a97cab1f5bfdd94c78a05cd02c0150a6b41ef82e8f466
-size 13687
+oid sha256:3f74e1b75f3e5a03df1b6b0e4509a4bf5da033c4f68cf88705ed136b471ae38c
+size 14691
diff --git a/tools/localazy/config.json b/tools/localazy/config.json
index a365379344..fe2f7d3e03 100644
--- a/tools/localazy/config.json
+++ b/tools/localazy/config.json
@@ -92,6 +92,13 @@
"error_no_compatible_app_found"
]
},
+ {
+ "name" : ":libraries:mediaviewer:impl",
+ "includeRegex" : [
+ "screen\\.media_details\\..*",
+ "screen_media_browser_.*"
+ ]
+ },
{
"name" : ":libraries:eventformatter:impl",
"includeRegex" : [