Merge pull request #4010 from element-hq/feature/bma/mediaGalleryUi

Media gallery UI
This commit is contained in:
Benoit Marty 2024-12-11 12:20:30 +01:00 committed by GitHub
commit 06d6ba1899
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
179 changed files with 4636 additions and 277 deletions

View file

@ -14,4 +14,13 @@
<string name="screen_knock_requests_list_empty_state_description">"Když někdo požádá o vstup do místnosti, uvidíte jeho žádost zde."</string>
<string name="screen_knock_requests_list_empty_state_title">"Žádná čekající žádost o vstup"</string>
<string name="screen_knock_requests_list_title">"Žádosti o vstup"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s +%2$d další chce vstoupit do této místnosti"</item>
<item quantity="few">"%1$s +%2$d další chtějí vstoupit do této místnosti"</item>
<item quantity="other">"%1$s +%2$d dalších chce vstoupit do této místnosti"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Zobrazit vše"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Přijmout"</string>
<string name="screen_room_single_knock_request_title">"%1$s chce vstoupit do této místnosti"</string>
<string name="screen_room_single_knock_request_view_button_title">"Zobrazit"</string>
</resources>

View file

@ -14,4 +14,12 @@
<string name="screen_knock_requests_list_empty_state_description">"Falls jemand um Aufnahme in den Raum bittet, können Sie dessen Anfrage hier sehen."</string>
<string name="screen_knock_requests_list_empty_state_title">"Keine ausstehende Beitrittsanfrage"</string>
<string name="screen_knock_requests_list_title">"Beitrittsanfragen"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s+ %2$d andere wollen diesem Chatroom beitreten"</item>
<item quantity="other">"%1$s+ %2$d andere wollen diesem Chatroom beitreten"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Alles ansehen"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Akzeptieren"</string>
<string name="screen_room_single_knock_request_title">"%1$s möchte diesem Chatroom beitreten"</string>
<string name="screen_room_single_knock_request_view_button_title">"Ansicht"</string>
</resources>

View file

@ -14,4 +14,12 @@
<string name="screen_knock_requests_list_empty_state_description">"Όταν κάποιος θα ζητήσει να συμμετάσχει στο δωμάτιο, θα μπορείς να δεις το αίτημά του εδώ."</string>
<string name="screen_knock_requests_list_empty_state_title">"Δεν υπάρχει εκκρεμές αίτημα συμμετοχής"</string>
<string name="screen_knock_requests_list_title">"Αιτήματα συμμετοχής"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"Οι χρήστες %1$s +%2$d ακόμη θέλουν να συμμετάσχουν σε αυτό το δωμάτιο"</item>
<item quantity="other">"Οι χρήστες %1$s +%2$d ακόμη θέλουν να συμμετάσχουν σε αυτό το δωμάτιο"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Προβολή όλων"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Αποδοχή"</string>
<string name="screen_room_single_knock_request_title">"Ο χρήστης %1$s θέλει να μπει σε αυτό το δωμάτιο"</string>
<string name="screen_room_single_knock_request_view_button_title">"Προβολή"</string>
</resources>

View file

@ -14,4 +14,12 @@
<string name="screen_knock_requests_list_empty_state_description">"Kui keegi soovib jututoaga liituda, siis need päringud on kuvatud siin."</string>
<string name="screen_knock_requests_list_empty_state_title">"Pole ühtegi liitumispalvet"</string>
<string name="screen_knock_requests_list_title">"Liitumispalved"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s + veel %2$d kasutaja soovivad selle jututoaga liituda"</item>
<item quantity="other">"%1$s + veel %2$d kasutajat soovivad selle jututoaga liituda"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Vaata kõiki"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Nõustu"</string>
<string name="screen_room_single_knock_request_title">"%1$s soovib selle jututoaga liituda"</string>
<string name="screen_room_single_knock_request_view_button_title">"Vaata"</string>
</resources>

View file

@ -14,4 +14,12 @@
<string name="screen_knock_requests_list_empty_state_description">"Lorsque quelquun demandera à rejoindre le salon, vous pourrez voir sa demande ici."</string>
<string name="screen_knock_requests_list_empty_state_title">"Personne ne demande à rejoindre le salon"</string>
<string name="screen_knock_requests_list_title">"Demandes en attente"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s et %2$d autre personne souhaitent rejoindre ce salon"</item>
<item quantity="other">"%1$s et %2$d autres personnes souhaitent rejoindre ce salon"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Tout afficher"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Accepter"</string>
<string name="screen_room_single_knock_request_title">"%1$s souhaite rejoindre ce salon"</string>
<string name="screen_room_single_knock_request_view_button_title">"Voir"</string>
</resources>

View file

@ -14,4 +14,12 @@
<string name="screen_knock_requests_list_empty_state_description">"Ha valaki csatlakozni kíván a szobához, itt láthatja a kérését."</string>
<string name="screen_knock_requests_list_empty_state_title">"Nincs függőben lévő csatlakozási kérelem"</string>
<string name="screen_knock_requests_list_title">"Csatlakozási kérelmek"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s és még %2$d felhasználó szeretne csatlakozni ehhez a szobához"</item>
<item quantity="other">"%1$s és még %2$d felhasználó szeretne csatlakozni ehhez a szobához"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Összes megtekintése"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Elfogadás"</string>
<string name="screen_room_single_knock_request_title">"%1$s szeretne csatlakozni ehhez a szobához"</string>
<string name="screen_room_single_knock_request_view_button_title">"Megtekintés"</string>
</resources>

View file

@ -14,4 +14,12 @@
<string name="screen_knock_requests_list_empty_state_description">"Quando qualcuno ti chiederà di entrare nella stanza, potrai vedere la sua richiesta qui."</string>
<string name="screen_knock_requests_list_empty_state_title">"Nessuna richiesta di accesso in sospeso"</string>
<string name="screen_knock_requests_list_title">"Richieste di accesso"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s +%2$d vogliono entrare in questa stanza"</item>
<item quantity="other">"%1$s +%2$d vogliono entrare in questa stanza"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Visualizza tutte"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Accetta"</string>
<string name="screen_room_single_knock_request_title">"%1$s vuole entrare in questa stanza"</string>
<string name="screen_room_single_knock_request_view_button_title">"Visualizza"</string>
</resources>

View file

@ -14,4 +14,13 @@
<string name="screen_knock_requests_list_empty_state_description">"Вы сможете увидеть запрос, когда кто-то попросит присоединиться к комнате."</string>
<string name="screen_knock_requests_list_empty_state_title">"Нет ожидающих запросов на присоединение"</string>
<string name="screen_knock_requests_list_title">"Запросы на присоединение"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s +%2$d хочет присоединиться к этой комнате"</item>
<item quantity="few">"%1$s +%2$d хотят присоединиться к этой комнате"</item>
<item quantity="many">"%1$s +%2$d хотят присоединиться к этой комнате"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Показать все"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Принять"</string>
<string name="screen_room_single_knock_request_title">"%1$s хочет присоединиться к этой комнате"</string>
<string name="screen_room_single_knock_request_view_button_title">"Просмотр"</string>
</resources>

View file

@ -5,4 +5,13 @@
<string name="screen_knock_requests_list_empty_state_description">"Keď niekto požiada, aby sa pripojil k miestnosti, jeho žiadosť si môžete pozrieť tu."</string>
<string name="screen_knock_requests_list_empty_state_title">"Žiadna čakajúca žiadosť o pripojenie"</string>
<string name="screen_knock_requests_list_title">"Žiadosti o pripojenie"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s +%2$d ďalší chcú vstúpiť do tejto miestnosti"</item>
<item quantity="few">"%1$s +%2$d ďalší chcú vstúpiť do tejto miestnosti"</item>
<item quantity="other">"%1$s +%2$d ďalších chce vstúpiť do tejto miestnosti"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Zobraziť všetko"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Prijať"</string>
<string name="screen_room_single_knock_request_title">"%1$s chce vstúpiť do tejto miestnosti"</string>
<string name="screen_room_single_knock_request_view_button_title">"Zobraziť"</string>
</resources>

View file

@ -117,6 +117,7 @@ class MessagesFlowNode @AssistedInject constructor(
@Parcelize
data class MediaViewer(
val eventId: EventId?,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
@ -241,9 +242,11 @@ class MessagesFlowNode @AssistedInject constructor(
}
is NavTarget.MediaViewer -> {
val params = MediaViewerEntryPoint.Params(
eventId = navTarget.eventId,
mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource,
canShowInfo = true,
canDownload = true,
canShare = true,
)
@ -251,6 +254,10 @@ class MessagesFlowNode @AssistedInject constructor(
override fun onDone() {
overlay.hide()
}
override fun onViewInTimeline(eventId: EventId) {
viewInTimeline(eventId)
}
}
mediaViewerEntryPoint.nodeBuilder(this, buildContext)
.params(params)
@ -311,11 +318,7 @@ class MessagesFlowNode @AssistedInject constructor(
}
override fun onViewInTimelineClick(eventId: EventId) {
val permalinkData = PermalinkData.RoomLink(
roomIdOrAlias = room.roomId.toRoomIdOrAlias(),
eventId = eventId,
)
callbacks.forEach { it.onPermalinkClick(permalinkData, pushToBackstack = false) }
viewInTimeline(eventId)
}
override fun onRoomPermalinkClick(data: PermalinkData.RoomLink) {
@ -341,6 +344,14 @@ class MessagesFlowNode @AssistedInject constructor(
}
}
private fun viewInTimeline(eventId: EventId) {
val permalinkData = PermalinkData.RoomLink(
roomIdOrAlias = room.roomId.toRoomIdOrAlias(),
eventId = eventId,
)
callbacks.forEach { it.onPermalinkClick(permalinkData, pushToBackstack = false) }
}
private fun processEventClick(event: TimelineItem.Event): Boolean {
val navTarget = when (event.content) {
is TimelineItemImageContent -> {
@ -415,13 +426,16 @@ class MessagesFlowNode @AssistedInject constructor(
thumbnailSource: MediaSource?,
): NavTarget {
return NavTarget.MediaViewer(
eventId = event.eventId,
mediaInfo = MediaInfo(
filename = content.filename,
caption = content.caption,
mimeType = content.mimeType,
formattedFileSize = content.formattedFileSize,
fileExtension = content.fileExtension,
senderId = event.senderId,
senderName = event.safeSenderName,
senderAvatar = event.senderAvatar.url,
dateSent = event.sentTime,
),
mediaSource = mediaSource,

View file

@ -15,6 +15,7 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
@ -39,10 +40,13 @@ import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.overlay.operation.hide
import io.element.android.libraries.architecture.overlay.operation.show
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
@ -59,6 +63,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
private val messagesEntryPoint: MessagesEntryPoint,
private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint,
private val mediaViewerEntryPoint: MediaViewerEntryPoint,
private val mediaGalleryEntryPoint: MediaGalleryEntryPoint,
) : BaseFlowNode<RoomDetailsFlowNode.NavTarget>(
backstack = BackStack(
initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().first().initialElement.toNavTarget(),
@ -98,6 +103,9 @@ class RoomDetailsFlowNode @AssistedInject constructor(
@Parcelize
data object PollHistory : NavTarget
@Parcelize
data object MediaGallery : NavTarget
@Parcelize
data object AdminSettings : NavTarget
@ -136,6 +144,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
backstack.push(NavTarget.PollHistory)
}
override fun openMediaGallery() {
backstack.push(NavTarget.MediaGallery)
}
override fun openAdminSettings() {
backstack.push(NavTarget.AdminSettings)
}
@ -213,6 +225,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
override fun onDone() {
overlay.hide()
}
override fun onViewInTimeline(eventId: EventId) {
// Cannot happen
}
}
mediaViewerEntryPoint.nodeBuilder(this, buildContext)
.avatar(
@ -222,10 +238,29 @@ class RoomDetailsFlowNode @AssistedInject constructor(
.callback(callback)
.build()
}
is NavTarget.PollHistory -> {
pollHistoryEntryPoint.createNode(this, buildContext)
}
is NavTarget.MediaGallery -> {
val callback = object : MediaGalleryEntryPoint.Callback {
override fun onBackClick() {
backstack.pop()
}
override fun onViewInTimeline(eventId: EventId) {
val permalinkData = PermalinkData.RoomLink(
roomIdOrAlias = room.roomId.toRoomIdOrAlias(),
eventId = eventId,
)
plugins<RoomDetailsEntryPoint.Callback>().forEach {
it.onPermalinkClick(permalinkData, pushToBackstack = false)
}
}
}
mediaGalleryEntryPoint.nodeBuilder(this, buildContext)
.callback(callback)
.build()
}
is NavTarget.AdminSettings -> {
createNode<RolesAndPermissionsFlowNode>(buildContext)

View file

@ -45,6 +45,7 @@ class RoomDetailsNode @AssistedInject constructor(
fun openRoomNotificationSettings()
fun openAvatarPreview(name: String, url: String)
fun openPollHistory()
fun openMediaGallery()
fun openAdminSettings()
fun openPinnedMessagesList()
fun openKnockRequestsList()
@ -77,6 +78,10 @@ class RoomDetailsNode @AssistedInject constructor(
callbacks.forEach { it.openPollHistory() }
}
private fun openMediaGallery() {
callbacks.forEach { it.openMediaGallery() }
}
private fun onJoinCall() {
callbacks.forEach { it.onJoinCall() }
}
@ -143,6 +148,7 @@ class RoomDetailsNode @AssistedInject constructor(
invitePeople = ::invitePeople,
openAvatarPreview = ::openAvatarPreview,
openPollHistory = ::openPollHistory,
openMediaGallery = ::openMediaGallery,
openAdminSettings = this::openAdminSettings,
onJoinCallClick = ::onJoinCall,
onPinnedMessagesClick = ::openPinnedMessages,

View file

@ -17,6 +17,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
@ -79,6 +80,10 @@ class RoomDetailsPresenter @Inject constructor(
val isPublic by remember { derivedStateOf { roomInfo?.isPublic.orFalse() } }
val canShowPinnedMessages = isPinnedMessagesFeatureEnabled()
var canShowMediaGallery by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
canShowMediaGallery = featureFlagService.isFeatureEnabled(FeatureFlags.MediaGallery)
}
val pinnedMessagesCount by remember { derivedStateOf { roomInfo?.pinnedEventIds?.size } }
LaunchedEffect(Unit) {
@ -162,6 +167,7 @@ class RoomDetailsPresenter @Inject constructor(
isPublic = isPublic,
heroes = roomInfo?.heroes.orEmpty().toPersistentList(),
canShowPinnedMessages = canShowPinnedMessages,
canShowMediaGallery = canShowMediaGallery,
pinnedMessagesCount = pinnedMessagesCount,
canShowKnockRequests = canShowKnockRequests,
knockRequestsCount = knockRequestsCount,

View file

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

View file

@ -101,6 +101,7 @@ fun aRoomDetailsState(
isPublic: Boolean = true,
heroes: List<MatrixUser> = emptyList(),
canShowPinnedMessages: Boolean = true,
canShowMediaGallery: Boolean = true,
pinnedMessagesCount: Int? = null,
canShowKnockRequests: Boolean = false,
knockRequestsCount: Int? = null,
@ -126,6 +127,7 @@ fun aRoomDetailsState(
isPublic = isPublic,
heroes = heroes.toPersistentList(),
canShowPinnedMessages = canShowPinnedMessages,
canShowMediaGallery = canShowMediaGallery,
pinnedMessagesCount = pinnedMessagesCount,
canShowKnockRequests = canShowKnockRequests,
knockRequestsCount = knockRequestsCount,

View file

@ -101,6 +101,7 @@ fun RoomDetailsView(
invitePeople: () -> Unit,
openAvatarPreview: (name: String, url: String) -> Unit,
openPollHistory: () -> Unit,
openMediaGallery: () -> Unit,
openAdminSettings: () -> Unit,
onJoinCallClick: () -> Unit,
onPinnedMessagesClick: () -> Unit,
@ -219,7 +220,11 @@ fun RoomDetailsView(
PollsSection(
openPollHistory = openPollHistory
)
if (state.canShowMediaGallery) {
MediaGallerySection(
onClick = openMediaGallery
)
}
if (state.isEncrypted) {
SecuritySection()
}
@ -576,6 +581,19 @@ private fun PollsSection(
}
}
@Composable
private fun MediaGallerySection(
onClick: () -> Unit,
) {
PreferenceCategory {
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_details_media_gallery_title)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Image())),
onClick = onClick,
)
}
}
@Composable
private fun SecuritySection() {
PreferenceCategory(title = stringResource(R.string.screen_room_details_security_title)) {
@ -631,6 +649,7 @@ private fun ContentToPreview(state: RoomDetailsState) {
invitePeople = {},
openAvatarPreview = { _, _ -> },
openPollHistory = {},
openMediaGallery = {},
openAdminSettings = {},
onJoinCallClick = {},
onPinnedMessagesClick = {},

View file

@ -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<RoomDetailsEvent>()
@ -282,6 +293,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomD
invitePeople = invitePeople,
openAvatarPreview = openAvatarPreview,
openPollHistory = openPollHistory,
openMediaGallery = openMediaGallery,
openAdminSettings = openAdminSettings,
onJoinCallClick = onJoinCallClick,
onPinnedMessagesClick = onPinnedMessagesClick,

View file

@ -30,6 +30,7 @@ import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
@ -82,6 +83,10 @@ class UserProfileFlowNode @AssistedInject constructor(
override fun onDone() {
backstack.pop()
}
override fun onViewInTimeline(eventId: EventId) {
// Cannot happen
}
}
mediaViewerEntryPoint.nodeBuilder(this, buildContext)
.avatar(

View file

@ -61,3 +61,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"
}

View file

@ -0,0 +1,16 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.core.preview
val loremIpsum = """
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut la
bore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris
nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate v
elit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proide
nt, sunt in culpa qui officia deserunt mollit anim id est laborum.
""".trimIndent()

View file

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

View file

@ -154,4 +154,11 @@ enum class FeatureFlags(
defaultValue = { true },
isFinished = false,
),
MediaGallery(
key = "feature.media_gallery",
title = "Allow user to open the media gallery",
description = null,
defaultValue = { buildMeta -> buildMeta.buildType != BuildType.RELEASE },
isFinished = false,
),
}

View file

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

View file

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

View file

@ -78,6 +78,7 @@ import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener
import org.matrix.rustcomponents.sdk.RoomInfo
import org.matrix.rustcomponents.sdk.RoomInfoListener
import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.RoomMessageEventMessageType
import org.matrix.rustcomponents.sdk.TypingNotificationsListener
import org.matrix.rustcomponents.sdk.UserPowerLevelUpdate
import org.matrix.rustcomponents.sdk.WidgetCapabilities
@ -223,6 +224,26 @@ class RustMatrixRoom(
}
}
override suspend fun mediaTimeline(): Result<Timeline> {
return runCatching {
innerRoom.messageFilteredTimeline(
internalIdPrefix = "MediaGallery_",
allowedMessageTypes = listOf(
RoomMessageEventMessageType.FILE,
RoomMessageEventMessageType.IMAGE,
RoomMessageEventMessageType.VIDEO,
RoomMessageEventMessageType.AUDIO,
)
).let { inner ->
createTimeline(inner, mode = Timeline.Mode.MEDIA)
}
}.onFailure {
if (it is CancellationException) {
throw it
}
}
}
override fun destroy() {
roomCoroutineScope.cancel()
liveTimeline.close()

View file

@ -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<List<MatrixTimelineItem>> = 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 ->

View file

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

View file

@ -0,0 +1,28 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.api
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.EventId
interface MediaGalleryEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder {
fun callback(callback: Callback): NodeBuilder
fun build(): Node
}
interface Callback : Plugin {
fun onBackClick()
fun onViewInTimeline(eventId: EventId)
}
}

View file

@ -9,6 +9,7 @@ package io.element.android.libraries.mediaviewer.api
import android.os.Parcelable
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.parcelize.Parcelize
@Parcelize
@ -18,11 +19,14 @@ data class MediaInfo(
val mimeType: String,
val formattedFileSize: String,
val fileExtension: String,
val senderId: UserId?,
val senderName: String?,
val senderAvatar: String?,
val dateSent: String?,
) : Parcelable
fun anImageMediaInfo(
senderId: UserId? = UserId("@alice:server.org"),
caption: String? = null,
senderName: String? = null,
dateSent: String? = null,
@ -32,7 +36,9 @@ fun anImageMediaInfo(
mimeType = MimeTypes.Jpeg,
formattedFileSize = "4MB",
fileExtension = "jpg",
senderId = senderId,
senderName = senderName,
senderAvatar = null,
dateSent = dateSent,
)
@ -46,24 +52,31 @@ fun aVideoMediaInfo(
mimeType = MimeTypes.Mp4,
formattedFileSize = "14MB",
fileExtension = "mp4",
senderId = UserId("@alice:server.org"),
senderName = senderName,
senderAvatar = null,
dateSent = dateSent,
)
fun aPdfMediaInfo(
filename: String = "a pdf file.pdf",
caption: String? = null,
senderName: String? = null,
dateSent: String? = null,
): MediaInfo = MediaInfo(
filename = "a pdf file.pdf",
caption = null,
filename = filename,
caption = caption,
mimeType = MimeTypes.Pdf,
formattedFileSize = "23MB",
fileExtension = "pdf",
senderId = UserId("@alice:server.org"),
senderName = senderName,
senderAvatar = null,
dateSent = dateSent,
)
fun anApkMediaInfo(
senderId: UserId? = UserId("@alice:server.org"),
senderName: String? = null,
dateSent: String? = null,
): MediaInfo = MediaInfo(
@ -72,7 +85,9 @@ fun anApkMediaInfo(
mimeType = MimeTypes.Apk,
formattedFileSize = "50MB",
fileExtension = "apk",
senderId = senderId,
senderName = senderName,
senderAvatar = null,
dateSent = dateSent,
)
@ -85,6 +100,8 @@ fun anAudioMediaInfo(
mimeType = MimeTypes.Mp3,
formattedFileSize = "7MB",
fileExtension = "mp3",
senderId = UserId("@alice:server.org"),
senderName = senderName,
senderAvatar = null,
dateSent = dateSent,
)

View file

@ -12,6 +12,7 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
interface MediaViewerEntryPoint : FeatureEntryPoint {
@ -26,12 +27,15 @@ interface MediaViewerEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun onDone()
fun onViewInTimeline(eventId: EventId)
}
data class Params(
val eventId: EventId?,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val canShowInfo: Boolean,
val canDownload: Boolean,
val canShare: Boolean,
) : NodeInputs

View file

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

View file

@ -0,0 +1,36 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint
import io.element.android.libraries.mediaviewer.impl.gallery.root.MediaGalleryRootNode
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultMediaGalleryEntryPoint @Inject constructor() : MediaGalleryEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): MediaGalleryEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : MediaGalleryEntryPoint.NodeBuilder {
override fun callback(callback: MediaGalleryEntryPoint.Callback): MediaGalleryEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun build(): Node {
return parentNode.createNode<MediaGalleryRootNode>(buildContext, plugins)
}
}
}
}

View file

@ -14,6 +14,7 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
@ -41,17 +42,21 @@ class DefaultMediaViewerEntryPoint @Inject constructor() : MediaViewerEntryPoint
val mimeType = MimeTypes.Images
return params(
MediaViewerEntryPoint.Params(
eventId = null,
mediaInfo = MediaInfo(
filename = filename,
caption = null,
mimeType = mimeType,
formattedFileSize = "",
fileExtension = "",
senderId = UserId("@dummy:server.org"),
senderName = null,
senderAvatar = null,
dateSent = null,
),
mediaSource = MediaSource(url = avatarUrl),
thumbnailSource = null,
canShowInfo = false,
canDownload = false,
canShare = false,
)

View file

@ -0,0 +1,29 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.details
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaviewer.api.MediaInfo
sealed interface MediaBottomSheetState {
data object Hidden : MediaBottomSheetState
data class MediaDeleteConfirmationState(
val eventId: EventId,
val mediaInfo: MediaInfo,
val thumbnailSource: MediaSource?,
) : MediaBottomSheetState
data class MediaDetailsBottomSheetState(
val eventId: EventId?,
val canDelete: Boolean,
val mediaInfo: MediaInfo,
val thumbnailSource: MediaSource?,
) : MediaBottomSheetState
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,31 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaviewer.api.MediaInfo
sealed interface MediaGalleryEvents {
data class ChangeMode(val mode: MediaGalleryMode) : MediaGalleryEvents
data class LoadMore(val direction: Timeline.PaginationDirection) : MediaGalleryEvents
data class Share(val mediaItem: MediaItem.Event) : MediaGalleryEvents
data class SaveOnDisk(val mediaItem: MediaItem.Event) : MediaGalleryEvents
data class OpenInfo(val mediaItem: MediaItem.Event) : MediaGalleryEvents
data class ViewInTimeline(val eventId: EventId) : MediaGalleryEvents
data class ConfirmDelete(
val eventId: EventId,
val mediaInfo: MediaInfo,
val thumbnailSource: MediaSource?,
) : MediaGalleryEvents
data object CloseBottomSheet : MediaGalleryEvents
data class Delete(val eventId: EventId) : MediaGalleryEvents
}

View file

@ -0,0 +1,14 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery
import io.element.android.libraries.matrix.api.core.EventId
interface MediaGalleryNavigator {
fun onViewInTimelineClick(eventId: EventId)
}

View file

@ -0,0 +1,67 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
@ContributesNode(RoomScope::class)
class MediaGalleryNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: MediaGalleryPresenter.Factory,
) : Node(buildContext, plugins = plugins),
MediaGalleryNavigator {
private val presenter = presenterFactory.create(
navigator = this,
)
interface Callback : Plugin {
fun onBackClick()
fun onItemClick(item: MediaItem.Event)
fun onViewInTimeline(eventId: EventId)
}
private fun onBackClick() {
plugins<Callback>().forEach {
it.onBackClick()
}
}
override fun onViewInTimelineClick(eventId: EventId) {
plugins<Callback>().forEach {
it.onViewInTimeline(eventId)
}
}
private fun onItemClick(item: MediaItem.Event) {
plugins<Callback>().forEach {
it.onItemClick(item)
}
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
MediaGalleryView(
state = state,
onBackClick = ::onBackClick,
onItemClick = ::onItemClick,
modifier = modifier,
)
}
}

View file

@ -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<MediaGalleryState> {
@AssistedFactory
interface Factory {
fun create(
navigator: MediaGalleryNavigator,
): MediaGalleryPresenter
}
@Composable
override fun present(): MediaGalleryState {
val coroutineScope = rememberCoroutineScope()
var mode by remember { mutableStateOf(MediaGalleryMode.Images) }
val roomInfo by room.roomInfoFlow.collectAsState(null)
var mediaBottomSheetState by remember { mutableStateOf<MediaBottomSheetState>(MediaBottomSheetState.Hidden) }
var mediaItems by remember {
mutableStateOf<AsyncData<ImmutableList<MediaItem>>>(AsyncData.Uninitialized)
}
val groupedMediaItems by remember {
derivedStateOf {
mediaItemsPostProcessor.process(
mediaItems = mediaItems,
)
}
}
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
localMediaActions.Configure()
var timeline by remember { mutableStateOf<AsyncData<Timeline>>(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<Timeline>,
onItemsChange: (AsyncData<ImmutableList<MediaItem>>) -> 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<Timeline>,
eventId: EventId,
) = launch {
timeline.dataOrNull()?.redactEvent(
eventOrTransactionId = eventId.toEventOrTransactionId(),
reason = null,
)
}
private suspend fun downloadMedia(mediaItem: MediaItem.Event): Result<LocalMedia> {
return mediaLoader.downloadMediaFile(
source = mediaItem.mediaSource(),
mimeType = mediaItem.mediaInfo().mimeType,
filename = mediaItem.mediaInfo().filename
)
.mapCatching { mediaFile ->
localMediaFactory.createFromMediaFile(
mediaFile = mediaFile,
mediaInfo = mediaItem.mediaInfo()
)
}
}
private fun CoroutineScope.saveOnDisk(mediaItem: MediaItem.Event) = launch {
downloadMedia(mediaItem)
.mapCatching { localMedia ->
localMediaActions.saveOnDisk(localMedia)
}
.onSuccess {
val snackbarMessage = SnackbarMessage(CommonStrings.common_file_saved_on_disk_android)
snackbarDispatcher.post(snackbarMessage)
}
.onFailure {
val snackbarMessage = SnackbarMessage(mediaActionsError(it))
snackbarDispatcher.post(snackbarMessage)
}
}
private fun CoroutineScope.share(mediaItem: MediaItem.Event) = launch {
downloadMedia(mediaItem)
.mapCatching { localMedia ->
localMediaActions.share(localMedia)
}
.onFailure {
val snackbarMessage = SnackbarMessage(mediaActionsError(it))
snackbarDispatcher.post(snackbarMessage)
}
}
private fun mediaActionsError(throwable: Throwable): Int {
return if (throwable is ActivityNotFoundException) {
R.string.error_no_compatible_app_found
} else {
CommonStrings.error_unknown
}
}
}

View file

@ -0,0 +1,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<GroupedMediaItems>,
val mediaBottomSheetState: MediaBottomSheetState,
val snackbarMessage: SnackbarMessage?,
val eventSink: (MediaGalleryEvents) -> Unit,
)
data class GroupedMediaItems(
val imageAndVideoItems: ImmutableList<MediaItem>,
val fileItems: ImmutableList<MediaItem>,
)
enum class MediaGalleryMode(val stringResource: Int) {
Images(R.string.screen_media_browser_list_mode_media),
Files(R.string.screen_media_browser_list_mode_files),
}

View file

@ -0,0 +1,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<MediaGalleryState> {
override val values: Sequence<MediaGalleryState>
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<GroupedMediaItems> = AsyncData.Uninitialized,
mediaBottomSheetState: MediaBottomSheetState = MediaBottomSheetState.Hidden,
) = MediaGalleryState(
roomName = roomName,
mode = mode,
groupedMediaItems = groupedMediaItems,
mediaBottomSheetState = mediaBottomSheetState,
snackbarMessage = null,
eventSink = {}
)
private fun aGroupedMediaItems(
imageAndVideoItems: List<MediaItem> = emptyList(),
fileItems: List<MediaItem> = emptyList(),
) = GroupedMediaItems(
imageAndVideoItems = imageAndVideoItems.toImmutableList(),
fileItems = fileItems.toImmutableList(),
)

View file

@ -0,0 +1,436 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.PageTitle
import io.element.android.libraries.designsystem.components.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<MediaItem>,
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<MediaItem>,
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<MediaItem>,
eventSink: (MediaGalleryEvents) -> Unit,
onItemClick: (MediaItem.Event) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(files) { item ->
when (item) {
is MediaItem.File -> FileItemView(
item,
onClick = { onItemClick(item) },
onShareClick = { eventSink(MediaGalleryEvents.Share(item)) },
onDownloadClick = { eventSink(MediaGalleryEvents.SaveOnDisk(item)) },
onInfoClick = { eventSink(MediaGalleryEvents.OpenInfo(item)) },
)
is MediaItem.DateSeparator -> DateItemView(item)
is MediaItem.Image,
is MediaItem.Video -> {
// Should not happen
}
is MediaItem.LoadingIndicator -> {
LoadingMoreIndicator(item.direction)
val latestEventSink by rememberUpdatedState(eventSink)
LaunchedEffect(item.timestamp) {
latestEventSink(MediaGalleryEvents.LoadMore(item.direction))
}
}
}
}
}
}
@Composable
private fun MediaGalleryImageGrid(
imagesAndVideos: ImmutableList<MediaItem>,
eventSink: (MediaGalleryEvents) -> Unit,
onItemClick: (MediaItem.Event) -> Unit,
) {
val configuration = LocalConfiguration.current
val screenWidth = configuration.screenWidthDp.dp
val horizontalPadding = 16.dp
val itemSpacing = 4.dp
val availableWidth = screenWidth - horizontalPadding * 2
val minCellWidth = 80.dp
// Calculate the number of columns
val columns = max(1, (availableWidth / (minCellWidth + itemSpacing)).toInt())
LazyVerticalGrid(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = horizontalPadding),
columns = GridCells.Fixed(columns),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(
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 = {},
)
}

View file

@ -0,0 +1,102 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.mediaviewer.api.MediaInfo
sealed interface MediaItem {
data class DateSeparator(
val id: UniqueId,
val formattedDate: String,
) : MediaItem
data class LoadingIndicator(
val id: UniqueId,
val direction: Timeline.PaginationDirection,
val timestamp: Long,
) : MediaItem
sealed interface Event : MediaItem
data class Image(
val id: UniqueId,
val eventId: EventId?,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
) : Event {
val thumbnailMediaRequestData: MediaRequestData
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
}
data class Video(
val id: UniqueId,
val eventId: EventId?,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val duration: String?,
) : Event {
val thumbnailMediaRequestData: MediaRequestData
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
}
data class File(
val id: UniqueId,
val eventId: EventId?,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
) : Event
}
fun MediaItem.id(): UniqueId {
return when (this) {
is MediaItem.DateSeparator -> id
is MediaItem.LoadingIndicator -> id
is MediaItem.Image -> id
is MediaItem.Video -> id
is MediaItem.File -> id
}
}
fun MediaItem.Event.eventId(): EventId? {
return when (this) {
is MediaItem.Image -> eventId
is MediaItem.Video -> eventId
is MediaItem.File -> eventId
}
}
fun MediaItem.Event.mediaInfo(): MediaInfo {
return when (this) {
is MediaItem.Image -> mediaInfo
is MediaItem.Video -> mediaInfo
is MediaItem.File -> mediaInfo
}
}
fun MediaItem.Event.mediaSource(): MediaSource {
return when (this) {
is MediaItem.Image -> mediaSource
is MediaItem.Video -> mediaSource
is MediaItem.File -> mediaSource
}
}
fun MediaItem.Event.thumbnailSource(): MediaSource? {
return when (this) {
is MediaItem.Image -> thumbnailSource
is MediaItem.Video -> thumbnailSource
is MediaItem.File -> null
}
}

View file

@ -0,0 +1,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<ImmutableList<MediaItem>>,
): AsyncData<GroupedMediaItems> {
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<MediaItem>.process(): GroupedMediaItems {
val imageAndVideoItems = mutableListOf<MediaItem>()
val fileItems = mutableListOf<MediaItem>()
val imageAndVideoItemsSubList = mutableListOf<MediaItem.Event>()
val fileItemsSublist = mutableListOf<MediaItem.Event>()
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(),
)
}
}

View file

@ -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<ImmutableList<MediaItem>>(replay = 1)
private val lock = Mutex()
private val diffCache = MutableListDiffCache<MediaItem>()
private val diffCacheUpdater = DiffCacheUpdater<MatrixTimelineItem, MediaItem>(
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<ImmutableList<MediaItem>> = _timelineItems.distinctUntilChanged()
suspend fun replaceWith(
timelineItems: List<MatrixTimelineItem>,
) = withContext(dispatchers.computation) {
lock.withLock {
diffCacheUpdater.updateWith(timelineItems)
buildAndEmitTimelineItemStates(timelineItems)
}
}
private suspend fun buildAndEmitTimelineItemStates(
timelineItems: List<MatrixTimelineItem>,
) {
val newTimelineItemStates = ArrayList<MediaItem>()
for (index in diffCache.indices().reversed()) {
val cacheItem = diffCache.get(index)
if (cacheItem == null) {
buildAndCacheItem(timelineItems, index)?.also { timelineItemState ->
newTimelineItemStates.add(timelineItemState)
}
} else {
newTimelineItemStates.add(cacheItem)
}
}
_timelineItems.emit(newTimelineItemStates.toPersistentList())
}
private fun buildAndCacheItem(
timelineItems: List<MatrixTimelineItem>,
index: Int,
): MediaItem? {
val timelineItem =
when (val currentTimelineItem = timelineItems[index]) {
is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem)
is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem)
MatrixTimelineItem.Other -> null
}
diffCache[index] = timelineItem
return timelineItem
}
}

View file

@ -0,0 +1,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
}
}
}

View file

@ -0,0 +1,139 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery.root
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.architecture.BackstackWithOverlayBox
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.overlay.Overlay
import io.element.android.libraries.architecture.overlay.operation.hide
import io.element.android.libraries.architecture.overlay.operation.show
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryNode
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
import io.element.android.libraries.mediaviewer.impl.gallery.eventId
import io.element.android.libraries.mediaviewer.impl.gallery.mediaInfo
import io.element.android.libraries.mediaviewer.impl.gallery.mediaSource
import io.element.android.libraries.mediaviewer.impl.gallery.thumbnailSource
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)
class MediaGalleryRootNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val mediaViewerEntryPoint: MediaViewerEntryPoint
) : BaseFlowNode<MediaGalleryRootNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
overlay = Overlay(
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
) {
sealed interface NavTarget : Parcelable {
@Parcelize
data object Root : NavTarget
@Parcelize
data class MediaViewer(
val eventId: EventId?,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
) : NavTarget
}
private fun onBackClick() {
plugins<MediaGalleryEntryPoint.Callback>().forEach {
it.onBackClick()
}
}
private fun onViewInTimeline(eventId: EventId) {
plugins<MediaGalleryEntryPoint.Callback>().forEach {
it.onViewInTimeline(eventId)
}
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Root -> {
val callback = object : MediaGalleryNode.Callback {
override fun 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<MediaGalleryNode>(buildContext = buildContext, plugins = listOf(callback))
}
is NavTarget.MediaViewer -> {
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() {
overlay.hide()
}
override fun onViewInTimeline(eventId: EventId) {
this@MediaGalleryRootNode.onViewInTimeline(eventId)
}
}
mediaViewerEntryPoint.nodeBuilder(this, buildContext)
.params(
MediaViewerEntryPoint.Params(
eventId = navTarget.eventId,
mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource,
canShowInfo = true,
canDownload = true,
canShare = true,
)
)
.callback(callback)
.build()
}
}
}
@Composable
override fun View(modifier: Modifier) {
BackstackWithOverlayBox(modifier)
}
}

View file

@ -0,0 +1,45 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
@Composable
fun DateItemView(
item: MediaItem.DateSeparator,
modifier: Modifier = Modifier,
) {
Text(
modifier = modifier
.fillMaxWidth()
.padding(12.dp),
text = item.formattedDate,
textAlign = TextAlign.Center,
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
)
}
@PreviewsDayNight
@Composable
internal fun DateItemViewPreview(
@PreviewParameter(MediaItemDateSeparatorProvider::class) date: MediaItem.DateSeparator,
) = ElementPreview {
DateItemView(date)
}

View file

@ -0,0 +1,183 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.core.extensions.withBrackets
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
@Composable
fun FileItemView(
file: MediaItem.File,
onClick: () -> Unit,
onShareClick: () -> Unit,
onDownloadClick: () -> Unit,
onInfoClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(top = 20.dp, start = 16.dp, end = 16.dp),
) {
FilenameRow(
file = file,
onClick = onClick,
)
val caption = file.mediaInfo.caption
if (caption != null) {
Spacer(modifier = Modifier.height(16.dp))
Caption(caption)
}
Spacer(modifier = Modifier.height(16.dp))
ActionIconsRow(
onShareClick = onShareClick,
onDownloadClick = onDownloadClick,
onInfoClick = onInfoClick,
)
HorizontalDivider()
}
}
@Composable
private fun FilenameRow(
file: MediaItem.File,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(
color = ElementTheme.colors.bgSubtleSecondary,
shape = RoundedCornerShape(12.dp),
)
.clickable { onClick() }
.fillMaxWidth()
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier
.background(
color = ElementTheme.colors.bgActionSecondaryRest,
shape = CircleShape,
)
.size(32.dp)
.padding(6.dp),
imageVector = CompoundIcons.Attachment(),
contentDescription = null,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = file.mediaInfo.filename,
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val formattedSize = file.mediaInfo.formattedFileSize
if (formattedSize.isNotEmpty()) {
Text(
text = formattedSize.withBrackets(),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
)
}
}
}
@Composable
private fun Caption(caption: String) {
Text(
modifier = Modifier.fillMaxWidth(),
text = caption,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
)
}
@Composable
private fun ActionIconsRow(
onShareClick: () -> Unit,
onDownloadClick: () -> Unit,
onInfoClick: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
IconButton(
onClick = onShareClick,
) {
Icon(
imageVector = CompoundIcons.ShareAndroid(),
contentDescription = null,
)
}
IconButton(
onClick = onDownloadClick,
) {
Icon(
imageVector = CompoundIcons.Download(),
contentDescription = null,
)
}
IconButton(
onClick = onInfoClick,
) {
Icon(
imageVector = CompoundIcons.Info(),
contentDescription = null,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun FileItemViewPreview(
@PreviewParameter(MediaItemFileProvider::class) file: MediaItem.File,
) = ElementPreview {
FileItemView(
file = file,
onClick = {},
onShareClick = {},
onDownloadClick = {},
onInfoClick = {},
)
}

View file

@ -0,0 +1,74 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ImageItemView(
image: MediaItem.Image,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val bgColor = if (LocalInspectionMode.current) {
ElementTheme.colors.bgDecorative1
} else {
Color.Transparent
}
Box(
modifier = modifier
.aspectRatio(1f)
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.background(bgColor),
) {
var isLoaded by remember { mutableStateOf(false) }
AsyncImage(
modifier = Modifier
.fillMaxWidth()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier),
model = image.thumbnailMediaRequestData,
contentScale = ContentScale.Crop,
alignment = Alignment.Center,
contentDescription = null,
onState = { isLoaded = it is AsyncImagePainter.State.Success },
)
}
}
@PreviewsDayNight
@Composable
internal fun ImageItemViewPreview() = ElementPreview {
ImageItemView(
image = aMediaItemImage(),
onClick = {},
onLongClick = {},
)
}

View file

@ -0,0 +1,30 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
class MediaItemDateSeparatorProvider : PreviewParameterProvider<MediaItem.DateSeparator> {
override val values: Sequence<MediaItem.DateSeparator>
get() = sequenceOf(
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,
)
}

View file

@ -0,0 +1,45 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.core.preview.loremIpsum
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
class MediaItemFileProvider : PreviewParameterProvider<MediaItem.File> {
override val values: Sequence<MediaItem.File>
get() = sequenceOf(
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(""),
)
}

View file

@ -0,0 +1,31 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery.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,
)
}

View file

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

View file

@ -0,0 +1,39 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
class MediaItemVideoProvider : PreviewParameterProvider<MediaItem.Video> {
override val values: Sequence<MediaItem.Video>
get() = sequenceOf(
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,
)
}

View file

@ -0,0 +1,116 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun VideoItemView(
video: MediaItem.Video,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val bgColor = if (LocalInspectionMode.current) {
ElementTheme.colors.bgDecorative2
} else {
Color.Transparent
}
Box(
modifier = modifier
.aspectRatio(1f)
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.background(bgColor),
) {
var isLoaded by remember { mutableStateOf(false) }
AsyncImage(
modifier = Modifier
.fillMaxWidth()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier),
model = video.thumbnailMediaRequestData,
contentScale = ContentScale.Crop,
alignment = Alignment.Center,
contentDescription = null,
onState = { isLoaded = it is AsyncImagePainter.State.Success },
)
VideoInfoRow(
video = video,
modifier = Modifier.align(Alignment.BottomStart)
)
}
}
@Composable
private fun VideoInfoRow(
video: MediaItem.Video,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = CompoundIcons.VideoCallSolid(),
contentDescription = null
)
if (video.duration != null) {
Spacer(Modifier.weight(1f))
Text(
text = video.duration,
style = ElementTheme.typography.fontBodySmMedium,
color = ElementTheme.colors.textPrimary,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun VideoItemViewPreview(
@PreviewParameter(MediaItemVideoProvider::class) video: MediaItem.Video,
) = ElementPreview {
VideoItemView(
video = video,
onClick = {},
onLongClick = {},
)
}

View file

@ -18,6 +18,7 @@ import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaFile
import io.element.android.libraries.matrix.api.media.toFile
import io.element.android.libraries.mediaviewer.api.MediaInfo
@ -41,7 +42,9 @@ class AndroidLocalMediaFactory @Inject constructor(
name = mediaInfo.filename,
caption = mediaInfo.caption,
formattedFileSize = mediaInfo.formattedFileSize,
senderId = mediaInfo.senderId,
senderName = mediaInfo.senderName,
senderAvatar = mediaInfo.senderAvatar,
dateSent = mediaInfo.dateSent,
)
@ -56,7 +59,9 @@ class AndroidLocalMediaFactory @Inject constructor(
name = name,
caption = null,
formattedFileSize = formattedFileSize,
senderId = null,
senderName = null,
senderAvatar = null,
dateSent = null,
)
@ -66,7 +71,9 @@ class AndroidLocalMediaFactory @Inject constructor(
name: String?,
caption: String?,
formattedFileSize: String?,
senderId: UserId?,
senderName: String?,
senderAvatar: String?,
dateSent: String?,
): LocalMedia {
val resolvedMimeType = mimeType ?: context.getMimeType(uri) ?: MimeTypes.OctetStream
@ -81,7 +88,9 @@ class AndroidLocalMediaFactory @Inject constructor(
caption = caption,
formattedFileSize = fileSize,
fileExtension = fileExtension,
senderId = senderId,
senderName = senderName,
senderAvatar = senderAvatar,
dateSent = dateSent,
)
)

View file

@ -7,10 +7,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
}

View file

@ -0,0 +1,15 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.viewer
import io.element.android.libraries.matrix.api.core.EventId
interface MediaViewerNavigator {
fun onViewInTimelineClick(eventId: EventId)
fun onItemDeleted()
}

View file

@ -19,14 +19,16 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.compound.theme.ForcedDarkElementTheme
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
@ContributesNode(RoomScope::class)
open class MediaViewerNode @AssistedInject constructor(
class MediaViewerNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: MediaViewerPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
) : Node(buildContext, plugins = plugins),
MediaViewerNavigator {
private val inputs = inputs<MediaViewerEntryPoint.Params>()
private fun onDone() {
@ -35,7 +37,20 @@ open class MediaViewerNode @AssistedInject constructor(
}
}
private val presenter = presenterFactory.create(inputs)
override fun onViewInTimelineClick(eventId: EventId) {
plugins<MediaViewerEntryPoint.Callback>().forEach {
it.onViewInTimeline(eventId)
}
}
override fun onItemDeleted() {
onDone()
}
private val presenter = presenterFactory.create(
inputs = inputs,
navigator = this,
)
@Composable
override fun View(modifier: Modifier) {

View file

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

View file

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

View file

@ -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<MediaViewerState> {
override val values: Sequence<MediaViewerState>
@ -30,10 +33,10 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
caption = "A caption",
).let {
aMediaViewerState(
AsyncData.Success(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
it,
mediaInfo = it,
)
},
aVideoMediaInfo(
@ -42,69 +45,81 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
caption = "A caption",
).let {
aMediaViewerState(
AsyncData.Success(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
it,
mediaInfo = it,
)
},
aPdfMediaInfo().let {
aMediaViewerState(
AsyncData.Success(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
it,
mediaInfo = it,
)
},
aMediaViewerState(
AsyncData.Loading(),
anApkMediaInfo(),
downloadedMedia = AsyncData.Loading(),
mediaInfo = anApkMediaInfo(),
),
anApkMediaInfo().let {
aMediaViewerState(
AsyncData.Success(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
it,
mediaInfo = it,
)
},
aMediaViewerState(
AsyncData.Loading(),
anAudioMediaInfo(),
downloadedMedia = AsyncData.Loading(),
mediaInfo = anAudioMediaInfo(),
),
anAudioMediaInfo().let {
aMediaViewerState(
AsyncData.Success(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
it,
mediaInfo = it,
)
},
anImageMediaInfo().let {
aMediaViewerState(
AsyncData.Success(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
it,
mediaInfo = it,
canShowInfo = false,
canDownload = false,
canShare = false,
)
},
aMediaViewerState(
mediaBottomSheetState = aMediaDetailsBottomSheetState(),
),
aMediaViewerState(
mediaBottomSheetState = aMediaDeleteConfirmationState(),
),
)
}
fun aMediaViewerState(
downloadedMedia: AsyncData<LocalMedia> = 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,
)

View file

@ -68,6 +68,9 @@ import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.impl.R
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
import io.element.android.libraries.mediaviewer.impl.details.MediaDeleteConfirmationBottomSheet
import io.element.android.libraries.mediaviewer.impl.details.MediaDetailsBottomSheet
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaView
import io.element.android.libraries.mediaviewer.impl.local.PlayableState
import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState
@ -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,
)
}
}
}
)
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_media_browser_empty_state_subtitle">"Obrázky a videa nahraná do této místnosti budou zobrazeny zde."</string>
<string name="screen_media_browser_empty_state_title">"Zatím nebyla nahrána žádná média"</string>
<string name="screen_media_browser_list_loading_files">"Načítání souborů…"</string>
<string name="screen_media_browser_list_loading_media">"Načítání médií…"</string>
<string name="screen_media_browser_list_mode_files">"Soubory"</string>
<string name="screen_media_browser_list_mode_media">"Média"</string>
<string name="screen_media_browser_title">"Média a soubory"</string>
</resources>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_media_browser_empty_state_subtitle">"In diesen Chatroom hochgeladene Bilder und Videos werden hier angezeigt."</string>
<string name="screen_media_browser_empty_state_title">"Noch keine Medien hochgeladen"</string>
<string name="screen_media_browser_list_loading_files">"Dateien werden geladen…"</string>
<string name="screen_media_browser_list_loading_media">"Medien werden geladen…"</string>
<string name="screen_media_browser_list_mode_files">"Dateien"</string>
<string name="screen_media_browser_list_mode_media">"Medien"</string>
<string name="screen_media_browser_title">"Medien und Dateien"</string>
<string name="screen_media_details_file_format">"Dateiformat"</string>
<string name="screen_media_details_filename">"Dateiname"</string>
<string name="screen_media_details_uploaded_by">"Hochgeladen von"</string>
<string name="screen_media_details_uploaded_on">"Hochgeladen am"</string>
</resources>

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_media_browser_delete_confirmation_subtitle">"Järgnevaga eemaldame selle faili jututoast ka tema liikmed enam ei pääse failile ligi."</string>
<string name="screen_media_browser_delete_confirmation_title">"Kas kustutame faili?"</string>
<string name="screen_media_browser_empty_state_subtitle">"Antud jututuppa üleslaaditud pildid ja videod kuvatakse siin."</string>
<string name="screen_media_browser_empty_state_title">"Mitte keegi pole veel meediat üles laadinud"</string>
<string name="screen_media_browser_list_loading_files">"Laadime faile…"</string>
<string name="screen_media_browser_list_loading_media">"Laadime meediat…"</string>
<string name="screen_media_browser_list_mode_files">"Failid"</string>
<string name="screen_media_browser_list_mode_media">"Meedia"</string>
<string name="screen_media_browser_title">"Meedia ja failid"</string>
<string name="screen_media_details_file_format">"Failivorming"</string>
<string name="screen_media_details_filename">"Failinimi"</string>
<string name="screen_media_details_uploaded_by">"Üleslaadija"</string>
<string name="screen_media_details_uploaded_on">"Üleslaaditud"</string>
</resources>

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_media_browser_delete_confirmation_subtitle">"Ce fichier sera supprimé du salon et les membres ny auront plus accès."</string>
<string name="screen_media_browser_delete_confirmation_title">"Supprimer le fichier ?"</string>
<string name="screen_media_browser_empty_state_subtitle">"Les images et vidéos envoyées dans ce salon seront affichées ici."</string>
<string name="screen_media_browser_empty_state_title">"Aucun média na encore été envoyé dans ce salon"</string>
<string name="screen_media_browser_list_loading_files">"Chargement des fichiers…"</string>
<string name="screen_media_browser_list_loading_media">"Chargement des médias…"</string>
<string name="screen_media_browser_list_mode_files">"Fichiers"</string>
<string name="screen_media_browser_list_mode_media">"Média"</string>
<string name="screen_media_browser_title">"Médias et fichiers"</string>
<string name="screen_media_details_file_format">"Format du fichier"</string>
<string name="screen_media_details_filename">"Nom du fichier"</string>
<string name="screen_media_details_uploaded_by">"Envoyé par"</string>
<string name="screen_media_details_uploaded_on">"Envoyé le"</string>
</resources>

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_media_browser_delete_confirmation_subtitle">"Ez a fájl el lesz távolítva a szobából, és a tagok nem férhetnek hozzá."</string>
<string name="screen_media_browser_delete_confirmation_title">"Törli a fájlt?"</string>
<string name="screen_media_browser_empty_state_subtitle">"Az ebbe a szobába feltöltött képek és videók itt jelennek meg."</string>
<string name="screen_media_browser_empty_state_title">"Még nincs feltöltött média"</string>
<string name="screen_media_browser_list_loading_files">"Fájlok betöltése…"</string>
<string name="screen_media_browser_list_loading_media">"Média betöltése…"</string>
<string name="screen_media_browser_list_mode_files">"Fájlok"</string>
<string name="screen_media_browser_list_mode_media">"Média"</string>
<string name="screen_media_browser_title">"Média és fájlok"</string>
<string name="screen_media_details_file_format">"Fájlformátum"</string>
<string name="screen_media_details_filename">"Fájlnév"</string>
<string name="screen_media_details_uploaded_by">"Feltöltötte:"</string>
<string name="screen_media_details_uploaded_on">"Feltöltve:"</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_media_browser_empty_state_subtitle">"Le immagini e i video caricati in questa stanza verranno mostrati qui."</string>
<string name="screen_media_browser_empty_state_title">"Nessun file multimediale ancora caricato"</string>
<string name="screen_media_browser_list_mode_files">"File"</string>
<string name="screen_media_browser_list_mode_media">"Contenuti multimediali"</string>
<string name="screen_media_browser_title">"File e contenuti multimediali"</string>
</resources>

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_media_browser_delete_confirmation_subtitle">"Этот файл будет удален из комнаты и участники не будут иметь к нему доступ."</string>
<string name="screen_media_browser_delete_confirmation_title">"Удалить файл?"</string>
<string name="screen_media_browser_empty_state_subtitle">"Здесь будут показаны изображения и видео, загруженные в данную комнату."</string>
<string name="screen_media_browser_empty_state_title">"Пока что нет загруженных медиафайлов"</string>
<string name="screen_media_browser_list_loading_files">"Загрузка файлов…"</string>
<string name="screen_media_browser_list_loading_media">"Загрузка медиа…"</string>
<string name="screen_media_browser_list_mode_files">"Файлы"</string>
<string name="screen_media_browser_list_mode_media">"Медиа"</string>
<string name="screen_media_browser_title">"Медиа и файлы"</string>
<string name="screen_media_details_file_format">"Формат файла"</string>
<string name="screen_media_details_filename">"Имя файла"</string>
<string name="screen_media_details_uploaded_by">"Загружено"</string>
<string name="screen_media_details_uploaded_on">"Загружено на"</string>
</resources>

View file

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

View file

@ -0,0 +1,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),
)

View file

@ -0,0 +1,19 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.tests.testutils.lambda.lambdaError
class FakeMediaGalleryNavigator(
private val onViewInTimelineClickLambda: (EventId) -> Unit = { lambdaError() }
) : MediaGalleryNavigator {
override fun onViewInTimelineClick(eventId: EventId) {
onViewInTimelineClickLambda(eventId)
}
}

View file

@ -0,0 +1,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<EventId, Unit> { }
val navigator = FakeMediaGalleryNavigator(
onViewInTimelineClickLambda = onViewInTimelineClickLambda,
)
val presenter = createMediaGalleryPresenter(
navigator = navigator,
room = FakeMatrixRoom(
displayName = A_ROOM_NAME,
mediaTimelineResult = { Result.success(FakeTimeline()) },
)
)
presenter.test {
skipItems(2)
val initialState = awaitItem()
assertThat(initialState.mode).isEqualTo(MediaGalleryMode.Images)
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
assertThat(initialState.roomName).isEqualTo(A_ROOM_NAME)
assertThat(initialState.groupedMediaItems.dataOrNull()).isEqualTo(
GroupedMediaItems(
imageAndVideoItems = persistentListOf(),
fileItems = persistentListOf(),
)
)
assertThat(initialState.snackbarMessage).isNull()
}
}
@Test
fun `present - change mode`() = runTest {
val onViewInTimelineClickLambda = lambdaRecorder<EventId, Unit> { }
val navigator = FakeMediaGalleryNavigator(
onViewInTimelineClickLambda = onViewInTimelineClickLambda,
)
val presenter = createMediaGalleryPresenter(
navigator = navigator,
room = FakeMatrixRoom(
displayName = A_ROOM_NAME,
mediaTimelineResult = { Result.success(FakeTimeline()) },
)
)
presenter.test {
skipItems(2)
val initialState = awaitItem()
assertThat(initialState.mode).isEqualTo(MediaGalleryMode.Images)
initialState.eventSink(MediaGalleryEvents.ChangeMode(MediaGalleryMode.Files))
val state = awaitItem()
assertThat(state.mode).isEqualTo(MediaGalleryMode.Files)
state.eventSink(MediaGalleryEvents.ChangeMode(MediaGalleryMode.Images))
val imageModeState = awaitItem()
assertThat(imageModeState.mode).isEqualTo(MediaGalleryMode.Images)
}
}
@Test
fun `present - bottom sheet state - own message and can delete own`() = runTest {
`present - bottom sheet state - own message`(canDeleteOwn = true)
}
@Test
fun `present - bottom sheet state - own message and cannot delete own`() = runTest {
`present - bottom sheet state - own message`(canDeleteOwn = false)
}
private suspend fun 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<EventId, Unit> { }
val navigator = FakeMediaGalleryNavigator(
onViewInTimelineClickLambda = onViewInTimelineClickLambda,
)
val presenter = createMediaGalleryPresenter(
room = FakeMatrixRoom(
mediaTimelineResult = { Result.success(FakeTimeline()) },
),
navigator = navigator,
)
presenter.test {
skipItems(2)
val initialState = awaitItem()
initialState.eventSink(MediaGalleryEvents.ViewInTimeline(AN_EVENT_ID))
onViewInTimelineClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
}
}
private fun 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(),
)
}
}

View file

@ -0,0 +1,210 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.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<GroupedMediaItems>())
}
@Test
fun `process Failure`() {
val sut = MediaItemsPostProcessor()
val result = sut.process(AsyncData.Failure(AN_EXCEPTION))
assertThat(result).isEqualTo(AsyncData.Failure<GroupedMediaItems>(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<MediaItem>,
expectedImageAndVideoItems: List<MediaItem>,
expectedFileItems: List<MediaItem>,
) {
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(),
)
)
)
}
}

View file

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

View file

@ -0,0 +1,24 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.viewer
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.tests.testutils.lambda.lambdaError
class FakeMediaViewerNavigator(
private val onViewInTimelineClickLambda: (EventId) -> Unit = { lambdaError() },
private val onItemDeletedLambda: () -> Unit = { lambdaError() },
) : MediaViewerNavigator {
override fun onViewInTimelineClick(eventId: EventId) {
onViewInTimelineClickLambda(eventId)
}
override fun onItemDeleted() {
onItemDeletedLambda()
}
}

View file

@ -16,20 +16,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<EventOrTransactionId, String?, Result<Unit>> { _, _ ->
Result.success(Unit)
}
val timeline = FakeTimeline().apply {
this.redactEventLambda = redactEventLambda
}
val onItemDeletedLambda = lambdaRecorder<Unit> { }
val navigator = FakeMediaViewerNavigator(
onItemDeletedLambda = onItemDeletedLambda,
)
val presenter = createMediaViewerPresenter(
room = FakeMatrixRoom(
liveTimeline = timeline,
canRedactOwnResult = { Result.success(true) },
),
mediaViewerNavigator = navigator,
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized)
assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
val loadingState = awaitItem()
assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem()
assertThat(successState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
successState.eventSink(MediaViewerEvents.Delete(AN_EVENT_ID))
redactEventLambda.assertions()
.isCalledOnce()
.with(
value(AN_EVENT_ID.toEventOrTransactionId()),
value(null),
)
onItemDeletedLambda.assertions().isCalledOnce()
}
}
@Test
fun `present - view in timeline invokes the navigator`() = runTest {
val onViewInTimelineClickLambda = lambdaRecorder<EventId, Unit> { }
val navigator = FakeMediaViewerNavigator(
onViewInTimelineClickLambda = onViewInTimelineClickLambda,
)
val presenter = createMediaViewerPresenter(
mediaViewerNavigator = navigator,
room = FakeMatrixRoom(
canRedactOwnResult = { Result.success(true) },
)
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized)
assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
val loadingState = awaitItem()
assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem()
assertThat(successState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
successState.eventSink(MediaViewerEvents.ViewInTimeline(AN_EVENT_ID))
onViewInTimelineClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
}
}
private fun createMediaViewerPresenter(
matrixMediaLoader: FakeMatrixMediaLoader,
localMediaActions: FakeLocalMediaActions,
eventId: EventId? = null,
matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(),
localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
canShowInfo: Boolean = true,
canShare: Boolean = true,
canDownload: Boolean = true,
mediaViewerNavigator: MediaViewerNavigator = FakeMediaViewerNavigator(),
room: MatrixRoom = FakeMatrixRoom(
liveTimeline = FakeTimeline(),
),
): MediaViewerPresenter {
return MediaViewerPresenter(
inputs = MediaViewerEntryPoint.Params(
eventId = eventId,
mediaInfo = TESTED_MEDIA_INFO,
mediaSource = aMediaSource(),
thumbnailSource = null,
canShowInfo = canShowInfo,
canShare = canShare,
canDownload = canDownload,
),
@ -153,6 +371,8 @@ class MediaViewerPresenterTest {
mediaLoader = matrixMediaLoader,
localMediaActions = localMediaActions,
snackbarDispatcher = snackbarDispatcher,
navigator = mediaViewerNavigator,
room = room,
)
}
}

View file

@ -37,7 +37,9 @@ class FakeLocalMediaFactory(
mimeType = mimeType ?: fallbackMimeType,
formattedFileSize = formattedFileSize ?: fallbackFileSize,
fileExtension = fileExtensionExtractor.extractFromName(safeName),
senderId = null,
senderName = null,
senderAvatar = null,
dateSent = null
)
return aLocalMedia(uri, mediaInfo)

View file

@ -303,13 +303,6 @@ Důvod: %1$s."</string>
<string name="invite_friends_text">"Ahoj, ozvi se mi na %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Zatřeste zařízením pro nahlášení chyby"</string>
<string name="screen_media_browser_empty_state_subtitle">"Obrázky a videa nahraná do této místnosti budou zobrazeny zde."</string>
<string name="screen_media_browser_empty_state_title">"Zatím nebyla nahrána žádná média"</string>
<string name="screen_media_browser_list_loading_files">"Načítání souborů…"</string>
<string name="screen_media_browser_list_loading_media">"Načítání médií…"</string>
<string name="screen_media_browser_list_mode_files">"Soubory"</string>
<string name="screen_media_browser_list_mode_media">"Média"</string>
<string name="screen_media_browser_title">"Média a soubory"</string>
<string name="screen_media_picker_error_failed_selection">"Výběr média se nezdařil, zkuste to prosím znovu."</string>
<string name="screen_media_upload_preview_caption_warning">"Titulky nemusí být viditelné pro lidi, kteří používají starší aplikace."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Nahrání média se nezdařilo, zkuste to prosím znovu."</string>
@ -334,19 +327,10 @@ Důvod: %1$s."</string>
<string name="screen_resolve_send_failure_you_unsigned_device_title">"Vaše zpráva nebyla odeslána, protože jste neověřili jedno nebo více zařízení"</string>
<string name="screen_room_error_failed_processing_media">"Nahrání média se nezdařilo, zkuste to prosím znovu."</string>
<string name="screen_room_error_failed_retrieving_user_details">"Nepodařilo se načíst údaje o uživateli"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s +%2$d další chce vstoupit do této místnosti"</item>
<item quantity="few">"%1$s +%2$d další chtějí vstoupit do této místnosti"</item>
<item quantity="other">"%1$s +%2$d dalších chce vstoupit do této místnosti"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Zobrazit vše"</string>
<string name="screen_room_pinned_banner_indicator">"%1$s z %2$s"</string>
<string name="screen_room_pinned_banner_indicator_description">"%1$s Připnuté zprávy"</string>
<string name="screen_room_pinned_banner_loading_description">"Načítání zprávy…"</string>
<string name="screen_room_pinned_banner_view_all_button_title">"Zobrazit vše"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Přijmout"</string>
<string name="screen_room_single_knock_request_title">"%1$s chce vstoupit do této místnosti"</string>
<string name="screen_room_single_knock_request_view_button_title">"Zobrazit"</string>
<string name="screen_room_title">"Chat"</string>
<string name="screen_roomlist_knock_event_sent_description">"Žádost o vstup odeslána"</string>
<string name="screen_share_location_title">"Sdílet polohu"</string>

View file

@ -299,17 +299,6 @@ Grund: %1$s."</string>
<string name="invite_friends_text">"Hey, sprich mit mir auf %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Schüttel heftig zum Melden von Fehlern"</string>
<string name="screen_media_browser_empty_state_subtitle">"In diesen Chatroom hochgeladene Bilder und Videos werden hier angezeigt."</string>
<string name="screen_media_browser_empty_state_title">"Noch keine Medien hochgeladen"</string>
<string name="screen_media_browser_list_loading_files">"Dateien werden geladen…"</string>
<string name="screen_media_browser_list_loading_media">"Medien werden geladen…"</string>
<string name="screen_media_browser_list_mode_files">"Dateien"</string>
<string name="screen_media_browser_list_mode_media">"Medien"</string>
<string name="screen_media_browser_title">"Medien und Dateien"</string>
<string name="screen_media_details_file_format">"Dateiformat"</string>
<string name="screen_media_details_filename">"Dateiname"</string>
<string name="screen_media_details_uploaded_by">"Hochgeladen von"</string>
<string name="screen_media_details_uploaded_on">"Hochgeladen am"</string>
<string name="screen_media_picker_error_failed_selection">"Medienauswahl fehlgeschlagen, bitte versuche es erneut."</string>
<string name="screen_media_upload_preview_caption_warning">"Bildunterschriften sind für Nutzer älterer Apps möglicherweise nicht sichtbar."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Fehler beim Verarbeiten des hochgeladenen Mediums. Bitte versuche es erneut."</string>
@ -333,18 +322,10 @@ Grund: %1$s."</string>
<string name="screen_resolve_send_failure_you_unsigned_device_title">"Ihre Nachricht wurde nicht geschickt, da Sie eines oder mehrere Ihrer Geräte nicht verifiziert haben."</string>
<string name="screen_room_error_failed_processing_media">"Fehler beim Verarbeiten des hochgeladenen Mediums. Bitte versuche es erneut."</string>
<string name="screen_room_error_failed_retrieving_user_details">"Benutzerdetails konnten nicht abgerufen werden"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s+ %2$d andere wollen diesem Chatroom beitreten"</item>
<item quantity="other">"%1$s+ %2$d andere wollen diesem Chatroom beitreten"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Alles ansehen"</string>
<string name="screen_room_pinned_banner_indicator">"%1$s von %2$s"</string>
<string name="screen_room_pinned_banner_indicator_description">"%1$s fixierte Nachrichten"</string>
<string name="screen_room_pinned_banner_loading_description">"Nachricht wird geladen…"</string>
<string name="screen_room_pinned_banner_view_all_button_title">"Alle anzeigen"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Akzeptieren"</string>
<string name="screen_room_single_knock_request_title">"%1$s möchte diesem Chatroom beitreten"</string>
<string name="screen_room_single_knock_request_view_button_title">"Ansicht"</string>
<string name="screen_room_title">"Chat"</string>
<string name="screen_roomlist_knock_event_sent_description">"Beitrittsanfrage gesendet"</string>
<string name="screen_share_location_title">"Standort teilen"</string>

View file

@ -322,18 +322,10 @@
<string name="screen_resolve_send_failure_you_unsigned_device_title">"Το μήνυμά σου δεν στάλθηκε επειδή δεν έχεις επαληθεύσει τουλάχιστον μία από τις συσκευές σου"</string>
<string name="screen_room_error_failed_processing_media">"Αποτυχία μεταφόρτωσης μέσου, δοκίμασε ξανά."</string>
<string name="screen_room_error_failed_retrieving_user_details">"Δεν ήταν δυνατή η ανάκτηση στοιχείων χρήστη"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"Οι χρήστες %1$s +%2$d ακόμη θέλουν να συμμετάσχουν σε αυτό το δωμάτιο"</item>
<item quantity="other">"Οι χρήστες %1$s +%2$d ακόμη θέλουν να συμμετάσχουν σε αυτό το δωμάτιο"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Προβολή όλων"</string>
<string name="screen_room_pinned_banner_indicator">"%1$s από %2$s"</string>
<string name="screen_room_pinned_banner_indicator_description">"%1$s Καρφιτσωμένα μηνύματα"</string>
<string name="screen_room_pinned_banner_loading_description">"Φόρτωση μηνύματος…"</string>
<string name="screen_room_pinned_banner_view_all_button_title">"Προβολή Όλων"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Αποδοχή"</string>
<string name="screen_room_single_knock_request_title">"Ο χρήστης %1$s θέλει να μπει σε αυτό το δωμάτιο"</string>
<string name="screen_room_single_knock_request_view_button_title">"Προβολή"</string>
<string name="screen_room_title">"Συνομιλία"</string>
<string name="screen_roomlist_knock_event_sent_description">"Το αίτημα συμμετοχής στάλθηκε"</string>
<string name="screen_share_location_title">"Κοινή χρήση τοποθεσίας"</string>

View file

@ -299,13 +299,6 @@ Põhjus: %1$s."</string>
<string name="invite_friends_text">"Hei, suhtle minuga %1$s võrgus: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Veast teatamiseks raputa nutiseadet ägedalt"</string>
<string name="screen_media_browser_empty_state_subtitle">"Antud jututuppa üleslaaditud pildid ja videod kuvatakse siin."</string>
<string name="screen_media_browser_empty_state_title">"Mitte keegi pole veel meediat üles laadinud"</string>
<string name="screen_media_browser_list_loading_files">"Laadime faile…"</string>
<string name="screen_media_browser_list_loading_media">"Laadime meediat…"</string>
<string name="screen_media_browser_list_mode_files">"Failid"</string>
<string name="screen_media_browser_list_mode_media">"Meedia"</string>
<string name="screen_media_browser_title">"Meedia ja failid"</string>
<string name="screen_media_picker_error_failed_selection">"Meediafaili valimine ei õnnestunud. Palun proovi uuesti."</string>
<string name="screen_media_upload_preview_caption_warning">"Selgitused ja alapealkirjad ei pruugi olla nähtavad vanemate rakenduste kasutajatele."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Meediafaili töötlemine enne üleslaadimist ei õnnestunud. Palun proovi uuesti."</string>
@ -329,18 +322,10 @@ Põhjus: %1$s."</string>
<string name="screen_resolve_send_failure_you_unsigned_device_title">"Kuna sul on üks või enam verifitseerimata seadet, siis sinu sõnum jäi saatmata"</string>
<string name="screen_room_error_failed_processing_media">"Meediafaili töötlemine enne üleslaadimist ei õnnestunud. Palun proovi uuesti."</string>
<string name="screen_room_error_failed_retrieving_user_details">"Kasutaja andmete laadimine ei õnnestunud"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s + veel %2$d kasutaja soovivad selle jututoaga liituda"</item>
<item quantity="other">"%1$s + veel %2$d kasutajat soovivad selle jututoaga liituda"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Vaata kõiki"</string>
<string name="screen_room_pinned_banner_indicator">"%1$s / %2$s"</string>
<string name="screen_room_pinned_banner_indicator_description">"%1$s esiletõstetud sõnumit"</string>
<string name="screen_room_pinned_banner_loading_description">"Laadime sõnumit…"</string>
<string name="screen_room_pinned_banner_view_all_button_title">"Näita kõiki"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Nõustu"</string>
<string name="screen_room_single_knock_request_title">"%1$s soovib selle jututoaga liituda"</string>
<string name="screen_room_single_knock_request_view_button_title">"Vaata"</string>
<string name="screen_room_title">"Vestlus"</string>
<string name="screen_roomlist_knock_event_sent_description">"Liitumispäring on saadetud"</string>
<string name="screen_share_location_title">"Jaga asukohta"</string>

View file

@ -299,19 +299,6 @@ Raison : %1$s."</string>
<string name="invite_friends_text">"Salut, parle-moi sur %1$s : %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Rageshake pour signaler un problème"</string>
<string name="screen_media_browser_delete_confirmation_subtitle">"Ce fichier sera supprimé du salon et les membres ny auront plus accès."</string>
<string name="screen_media_browser_delete_confirmation_title">"Supprimer le fichier ?"</string>
<string name="screen_media_browser_empty_state_subtitle">"Les images et vidéos envoyées dans ce salon seront affichées ici."</string>
<string name="screen_media_browser_empty_state_title">"Aucun média na encore été envoyé dans ce salon"</string>
<string name="screen_media_browser_list_loading_files">"Chargement des fichiers…"</string>
<string name="screen_media_browser_list_loading_media">"Chargement des médias…"</string>
<string name="screen_media_browser_list_mode_files">"Fichiers"</string>
<string name="screen_media_browser_list_mode_media">"Média"</string>
<string name="screen_media_browser_title">"Médias et fichiers"</string>
<string name="screen_media_details_file_format">"Format du fichier"</string>
<string name="screen_media_details_filename">"Nom du fichier"</string>
<string name="screen_media_details_uploaded_by">"Envoyé par"</string>
<string name="screen_media_details_uploaded_on">"Envoyé le"</string>
<string name="screen_media_picker_error_failed_selection">"Échec de la sélection du média, veuillez réessayer."</string>
<string name="screen_media_upload_preview_caption_warning">"Les légendes peuvent ne pas être visibles pour les utilisateurs danciennes applications."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Échec du traitement des médias à télécharger, veuillez réessayer."</string>
@ -335,18 +322,10 @@ Raison : %1$s."</string>
<string name="screen_resolve_send_failure_you_unsigned_device_title">"Votre message na pas été envoyé car vous navez pas vérifié tous vos appareils"</string>
<string name="screen_room_error_failed_processing_media">"Échec du traitement des médias à télécharger, veuillez réessayer."</string>
<string name="screen_room_error_failed_retrieving_user_details">"Impossible de récupérer les détails de lutilisateur"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s et %2$d autre personne souhaitent rejoindre ce salon"</item>
<item quantity="other">"%1$s et %2$d autres personnes souhaitent rejoindre ce salon"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Tout afficher"</string>
<string name="screen_room_pinned_banner_indicator">"%1$s sur %2$s"</string>
<string name="screen_room_pinned_banner_indicator_description">"%1$s Messages épinglés"</string>
<string name="screen_room_pinned_banner_loading_description">"Chargement du message…"</string>
<string name="screen_room_pinned_banner_view_all_button_title">"Voir tout"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Accepter"</string>
<string name="screen_room_single_knock_request_title">"%1$s souhaite rejoindre ce salon"</string>
<string name="screen_room_single_knock_request_view_button_title">"Voir"</string>
<string name="screen_room_title">"Discussion"</string>
<string name="screen_roomlist_knock_event_sent_description">"Demande dadhésion envoyée"</string>
<string name="screen_share_location_title">"Partage de position"</string>

View file

@ -299,19 +299,6 @@ Ok: %1$s."</string>
<string name="invite_friends_text">"Beszélgessünk itt: %1$s, %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Az eszköz rázása a hibajelentéshez"</string>
<string name="screen_media_browser_delete_confirmation_subtitle">"Ez a fájl el lesz távolítva a szobából, és a tagok nem férhetnek hozzá."</string>
<string name="screen_media_browser_delete_confirmation_title">"Törli a fájlt?"</string>
<string name="screen_media_browser_empty_state_subtitle">"Az ebbe a szobába feltöltött képek és videók itt jelennek meg."</string>
<string name="screen_media_browser_empty_state_title">"Még nincs feltöltött média"</string>
<string name="screen_media_browser_list_loading_files">"Fájlok betöltése…"</string>
<string name="screen_media_browser_list_loading_media">"Média betöltése…"</string>
<string name="screen_media_browser_list_mode_files">"Fájlok"</string>
<string name="screen_media_browser_list_mode_media">"Média"</string>
<string name="screen_media_browser_title">"Média és fájlok"</string>
<string name="screen_media_details_file_format">"Fájlformátum"</string>
<string name="screen_media_details_filename">"Fájlnév"</string>
<string name="screen_media_details_uploaded_by">"Feltöltötte:"</string>
<string name="screen_media_details_uploaded_on">"Feltöltve:"</string>
<string name="screen_media_picker_error_failed_selection">"Nem sikerült kiválasztani a médiát, próbálja újra."</string>
<string name="screen_media_upload_preview_caption_warning">"Előfordulhat, hogy a feliratok nem láthatók a régebbi alkalmazásokat használók számára."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Nem sikerült feldolgozni a feltöltendő médiát, próbálja újra."</string>
@ -335,18 +322,10 @@ Ok: %1$s."</string>
<string name="screen_resolve_send_failure_you_unsigned_device_title">"Az üzenet nem lett elküldve, mert egy vagy több eszközét nem ellenőrizte"</string>
<string name="screen_room_error_failed_processing_media">"Nem sikerült feldolgozni a feltöltendő médiát, próbálja újra."</string>
<string name="screen_room_error_failed_retrieving_user_details">"Nem sikerült letölteni a felhasználói adatokat"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s és még %2$d felhasználó szeretne csatlakozni ehhez a szobához"</item>
<item quantity="other">"%1$s és még %2$d felhasználó szeretne csatlakozni ehhez a szobához"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Összes megtekintése"</string>
<string name="screen_room_pinned_banner_indicator">"%1$s / %2$s"</string>
<string name="screen_room_pinned_banner_indicator_description">"%1$s kitűzött üzenet"</string>
<string name="screen_room_pinned_banner_loading_description">"Üzenet betöltése…"</string>
<string name="screen_room_pinned_banner_view_all_button_title">"Összes megtekintése"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Elfogadás"</string>
<string name="screen_room_single_knock_request_title">"%1$s szeretne csatlakozni ehhez a szobához"</string>
<string name="screen_room_single_knock_request_view_button_title">"Megtekintés"</string>
<string name="screen_room_title">"Csevegés"</string>
<string name="screen_roomlist_knock_event_sent_description">"Csatlakozási kérés elküldve"</string>
<string name="screen_share_location_title">"Hely megosztása"</string>

View file

@ -299,11 +299,6 @@ Motivo:. %1$s"</string>
<string name="invite_friends_text">"Ehi, parliamo su %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Scuoti per segnalare un problema"</string>
<string name="screen_media_browser_empty_state_subtitle">"Le immagini e i video caricati in questa stanza verranno mostrati qui."</string>
<string name="screen_media_browser_empty_state_title">"Nessun file multimediale ancora caricato"</string>
<string name="screen_media_browser_list_mode_files">"File"</string>
<string name="screen_media_browser_list_mode_media">"Contenuti multimediali"</string>
<string name="screen_media_browser_title">"File e contenuti multimediali"</string>
<string name="screen_media_picker_error_failed_selection">"Selezione del file multimediale fallita, riprova."</string>
<string name="screen_media_upload_preview_caption_warning">"Le didascalie potrebbero non essere visibili agli utenti di app meno recenti."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Elaborazione del file multimediale da caricare fallita, riprova."</string>
@ -327,18 +322,10 @@ Motivo:. %1$s"</string>
<string name="screen_resolve_send_failure_you_unsigned_device_title">"Il tuo messaggio non è stato inviato perché non hai verificato uno o più dispositivi."</string>
<string name="screen_room_error_failed_processing_media">"Elaborazione del file multimediale da caricare fallita, riprova."</string>
<string name="screen_room_error_failed_retrieving_user_details">"Impossibile recuperare i dettagli dell\'utente"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s +%2$d vogliono entrare in questa stanza"</item>
<item quantity="other">"%1$s +%2$d vogliono entrare in questa stanza"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Visualizza tutte"</string>
<string name="screen_room_pinned_banner_indicator">"%1$s di %2$s"</string>
<string name="screen_room_pinned_banner_indicator_description">"%1$s Messaggi fissati"</string>
<string name="screen_room_pinned_banner_loading_description">"Caricamento messaggio…"</string>
<string name="screen_room_pinned_banner_view_all_button_title">"Mostra tutti"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Accetta"</string>
<string name="screen_room_single_knock_request_title">"%1$s vuole entrare in questa stanza"</string>
<string name="screen_room_single_knock_request_view_button_title">"Visualizza"</string>
<string name="screen_room_title">"Conversazione"</string>
<string name="screen_roomlist_knock_event_sent_description">"Richiesta di accesso inviata"</string>
<string name="screen_share_location_title">"Condividi posizione"</string>

View file

@ -303,13 +303,6 @@
<string name="invite_friends_text">"Привет, поговори со мной по %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Встряхните устройство, чтобы сообщить об ошибке"</string>
<string name="screen_media_browser_empty_state_subtitle">"Здесь будут показаны изображения и видео, загруженные в данную комнату."</string>
<string name="screen_media_browser_empty_state_title">"Пока что нет загруженных медиафайлов"</string>
<string name="screen_media_browser_list_loading_files">"Загрузка файлов…"</string>
<string name="screen_media_browser_list_loading_media">"Загрузка медиа…"</string>
<string name="screen_media_browser_list_mode_files">"Файлы"</string>
<string name="screen_media_browser_list_mode_media">"Медиа"</string>
<string name="screen_media_browser_title">"Медиа и файлы"</string>
<string name="screen_media_picker_error_failed_selection">"Не удалось выбрать носитель, попробуйте еще раз."</string>
<string name="screen_media_upload_preview_caption_warning">"Подпись может быть не видна пользователям старых приложений."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Не удалось обработать медиафайл для загрузки, попробуйте еще раз."</string>
@ -334,19 +327,10 @@
<string name="screen_resolve_send_failure_you_unsigned_device_title">"Ваше сообщение не было отправлено, поскольку вы не подтвердили одно или несколько своих устройств."</string>
<string name="screen_room_error_failed_processing_media">"Не удалось обработать медиафайл для загрузки, попробуйте еще раз."</string>
<string name="screen_room_error_failed_retrieving_user_details">"Не удалось получить данные о пользователе"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s +%2$d хочет присоединиться к этой комнате"</item>
<item quantity="few">"%1$s +%2$d хотят присоединиться к этой комнате"</item>
<item quantity="many">"%1$s +%2$d хотят присоединиться к этой комнате"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Показать все"</string>
<string name="screen_room_pinned_banner_indicator">"%1$s из %2$s"</string>
<string name="screen_room_pinned_banner_indicator_description">"%1$s Закрепленные сообщения"</string>
<string name="screen_room_pinned_banner_loading_description">"Загрузка сообщения…"</string>
<string name="screen_room_pinned_banner_view_all_button_title">"Посмотреть все"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Принять"</string>
<string name="screen_room_single_knock_request_title">"%1$s хочет присоединиться к этой комнате"</string>
<string name="screen_room_single_knock_request_view_button_title">"Просмотр"</string>
<string name="screen_room_title">"Чат"</string>
<string name="screen_roomlist_knock_event_sent_description">"Запрос на присоединение отправлен"</string>
<string name="screen_share_location_title">"Поделиться местоположением"</string>

View file

@ -323,19 +323,10 @@ Dôvod: %1$s."</string>
<string name="screen_resolve_send_failure_you_unsigned_device_title">"Vaša správa nebola odoslaná, pretože ste neoverili jedno alebo viac svojich zariadení"</string>
<string name="screen_room_error_failed_processing_media">"Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova."</string>
<string name="screen_room_error_failed_retrieving_user_details">"Nepodarilo sa získať údaje o používateľovi"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s +%2$d ďalší chcú vstúpiť do tejto miestnosti"</item>
<item quantity="few">"%1$s +%2$d ďalší chcú vstúpiť do tejto miestnosti"</item>
<item quantity="other">"%1$s +%2$d ďalších chce vstúpiť do tejto miestnosti"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Zobraziť všetko"</string>
<string name="screen_room_pinned_banner_indicator">"%1$s z %2$s"</string>
<string name="screen_room_pinned_banner_indicator_description">"%1$s Pripnutých správ"</string>
<string name="screen_room_pinned_banner_loading_description">"Načítava sa správa…"</string>
<string name="screen_room_pinned_banner_view_all_button_title">"Zobraziť všetko"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Prijať"</string>
<string name="screen_room_single_knock_request_title">"%1$s chce vstúpiť do tejto miestnosti"</string>
<string name="screen_room_single_knock_request_view_button_title">"Zobraziť"</string>
<string name="screen_room_title">"Konverzácia"</string>
<string name="screen_roomlist_knock_event_sent_description">"Žiadosť o vstup odoslaná"</string>
<string name="screen_share_location_title">"Zdieľať polohu"</string>

View file

@ -148,6 +148,7 @@
<string name="common_device_id">"Device ID"</string>
<string name="common_direct_chat">"Direct chat"</string>
<string name="common_do_not_show_this_again">"Do not show this again"</string>
<string name="common_downloading">"Downloading"</string>
<string name="common_edited_suffix">"(edited)"</string>
<string name="common_editing">"Editing"</string>
<string name="common_editing_caption">"Editing caption"</string>
@ -299,19 +300,6 @@ Reason: %1$s."</string>
<string name="invite_friends_text">"Hey, talk to me on %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Rageshake to report bug"</string>
<string name="screen_media_browser_delete_confirmation_subtitle">"This file will be removed from the room and members wont have access to it."</string>
<string name="screen_media_browser_delete_confirmation_title">"Delete file?"</string>
<string name="screen_media_browser_empty_state_subtitle">"Images and videos uploaded to this room will be shown here."</string>
<string name="screen_media_browser_empty_state_title">"No media uploaded yet"</string>
<string name="screen_media_browser_list_loading_files">"Loading files…"</string>
<string name="screen_media_browser_list_loading_media">"Loading media…"</string>
<string name="screen_media_browser_list_mode_files">"Files"</string>
<string name="screen_media_browser_list_mode_media">"Media"</string>
<string name="screen_media_browser_title">"Media and files"</string>
<string name="screen_media_details_file_format">"File format"</string>
<string name="screen_media_details_filename">"File name"</string>
<string name="screen_media_details_uploaded_by">"Uploaded by"</string>
<string name="screen_media_details_uploaded_on">"Uploaded on"</string>
<string name="screen_media_picker_error_failed_selection">"Failed selecting media, please try again."</string>
<string name="screen_media_upload_preview_caption_warning">"Captions might not be visible to people using older apps."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Failed processing media to upload, please try again."</string>
@ -355,6 +343,7 @@ Reason: %1$s."</string>
<string name="test_language_identifier">"en"</string>
<string name="test_untranslated_default_language_identifier">"en"</string>
<string name="timeline_decryption_failure_historical_event_no_key_backup">"Historical messages are not available on this device"</string>
<string name="timeline_decryption_failure_historical_event_user_not_joined">"You don\'t have access to this message"</string>
<string name="timeline_decryption_failure_unable_to_decrypt">"Unable to decrypt message"</string>
<string name="timeline_decryption_failure_withheld_unverified">"This message was blocked either because you did not verify your device or because the sender needs to verify your identity."</string>
</resources>

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:94f040a3d18493f80b5f90eb48e68c664de5ddee0ae4575905ce35709d31abe9
size 40969
oid sha256:c7828106cd3724769c5bbeaad50c3417264abcb6af40ffd90aee283e8b29e579
size 41831

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:96cf72bdceae29a86593ed3bd02d5edbe1f5422e5be0798f536b49805088b0b8
size 45109
oid sha256:f9c24abc59ed8ef26f647b5d3855b768af6043bdbb035f0d2756a7b783f64561
size 39848

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0c719ba2c0782ecf8ebf37c07dbc79d37b1d993e4987388ceafbefb31b03d100
size 44064
oid sha256:b87d165479dd2a0d6497fdac37bb43de760fd0eade06ad23b53baff667b8af03
size 38783

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a675afee3fcef0f8468ec93e33e1e86398bda517f4f54615aaf527d549387431
size 47217
oid sha256:2e7726872c78a2bcddfefa689699d7bf69a09b55c023bbc7008575fbec5b7779
size 41986

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ced35352da8f7b6c9d4a5647cf1ff29f194d4f68ca9eec9c268ab889271e4776
size 45507
oid sha256:6235b9e9b6ffb9e4d813cfa49c9819a3cbc112d6dc5d25d7901a257e4353e609
size 40241

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3444cc70e1f1b212d89ba404199a439a498281aa9faff9a9bb2469b727498224
size 37486
oid sha256:75bcd07324f5acb45552d8d5bbe369cece798d109f3c096859c6a88fccc8e2e1
size 39797

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:afcf1a235cd16b501ec02f7da90cf4800df41ab07383b7e6ed502f4e9249855e
size 38354
oid sha256:f69da1fef91846329a0835dfc2be82b1f49892193dadf139f6ac262355aff86e
size 39910

Some files were not shown because too many files have changed in this diff Show more