Merge pull request #4010 from element-hq/feature/bma/mediaGalleryUi
Media gallery UI
This commit is contained in:
commit
06d6ba1899
179 changed files with 4636 additions and 277 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -14,4 +14,12 @@
|
|||
<string name="screen_knock_requests_list_empty_state_description">"Lorsque quelqu’un 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
|
||||
@Parcelize
|
||||
data class MediaViewer(
|
||||
val eventId: EventId?,
|
||||
val mediaInfo: MediaInfo,
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
|
|
@ -241,9 +242,11 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
}
|
||||
is NavTarget.MediaViewer -> {
|
||||
val params = MediaViewerEntryPoint.Params(
|
||||
eventId = navTarget.eventId,
|
||||
mediaInfo = navTarget.mediaInfo,
|
||||
mediaSource = navTarget.mediaSource,
|
||||
thumbnailSource = navTarget.thumbnailSource,
|
||||
canShowInfo = true,
|
||||
canDownload = true,
|
||||
canShare = true,
|
||||
)
|
||||
|
|
@ -251,6 +254,10 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
override fun onDone() {
|
||||
overlay.hide()
|
||||
}
|
||||
|
||||
override fun onViewInTimeline(eventId: EventId) {
|
||||
viewInTimeline(eventId)
|
||||
}
|
||||
}
|
||||
mediaViewerEntryPoint.nodeBuilder(this, buildContext)
|
||||
.params(params)
|
||||
|
|
@ -311,11 +318,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
}
|
||||
|
||||
override fun onViewInTimelineClick(eventId: EventId) {
|
||||
val permalinkData = PermalinkData.RoomLink(
|
||||
roomIdOrAlias = room.roomId.toRoomIdOrAlias(),
|
||||
eventId = eventId,
|
||||
)
|
||||
callbacks.forEach { it.onPermalinkClick(permalinkData, pushToBackstack = false) }
|
||||
viewInTimeline(eventId)
|
||||
}
|
||||
|
||||
override fun onRoomPermalinkClick(data: PermalinkData.RoomLink) {
|
||||
|
|
@ -341,6 +344,14 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun viewInTimeline(eventId: EventId) {
|
||||
val permalinkData = PermalinkData.RoomLink(
|
||||
roomIdOrAlias = room.roomId.toRoomIdOrAlias(),
|
||||
eventId = eventId,
|
||||
)
|
||||
callbacks.forEach { it.onPermalinkClick(permalinkData, pushToBackstack = false) }
|
||||
}
|
||||
|
||||
private fun processEventClick(event: TimelineItem.Event): Boolean {
|
||||
val navTarget = when (event.content) {
|
||||
is TimelineItemImageContent -> {
|
||||
|
|
@ -415,13 +426,16 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
thumbnailSource: MediaSource?,
|
||||
): NavTarget {
|
||||
return NavTarget.MediaViewer(
|
||||
eventId = event.eventId,
|
||||
mediaInfo = MediaInfo(
|
||||
filename = content.filename,
|
||||
caption = content.caption,
|
||||
mimeType = content.mimeType,
|
||||
formattedFileSize = content.formattedFileSize,
|
||||
fileExtension = content.fileExtension,
|
||||
senderId = event.senderId,
|
||||
senderName = event.safeSenderName,
|
||||
senderAvatar = event.senderAvatar.url,
|
||||
dateSent = event.sentTime,
|
||||
),
|
||||
mediaSource = mediaSource,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import com.bumble.appyx.core.node.Node
|
|||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.core.plugin.plugins
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import com.bumble.appyx.navmodel.backstack.operation.pop
|
||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
|
|
@ -39,10 +40,13 @@ import io.element.android.libraries.architecture.createNode
|
|||
import io.element.android.libraries.architecture.overlay.operation.hide
|
||||
import io.element.android.libraries.architecture.overlay.operation.show
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
|
||||
|
|
@ -59,6 +63,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
|||
private val messagesEntryPoint: MessagesEntryPoint,
|
||||
private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint,
|
||||
private val mediaViewerEntryPoint: MediaViewerEntryPoint,
|
||||
private val mediaGalleryEntryPoint: MediaGalleryEntryPoint,
|
||||
) : BaseFlowNode<RoomDetailsFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().first().initialElement.toNavTarget(),
|
||||
|
|
@ -98,6 +103,9 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
|||
@Parcelize
|
||||
data object PollHistory : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object MediaGallery : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object AdminSettings : NavTarget
|
||||
|
||||
|
|
@ -136,6 +144,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
|||
backstack.push(NavTarget.PollHistory)
|
||||
}
|
||||
|
||||
override fun openMediaGallery() {
|
||||
backstack.push(NavTarget.MediaGallery)
|
||||
}
|
||||
|
||||
override fun openAdminSettings() {
|
||||
backstack.push(NavTarget.AdminSettings)
|
||||
}
|
||||
|
|
@ -213,6 +225,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
|||
override fun onDone() {
|
||||
overlay.hide()
|
||||
}
|
||||
|
||||
override fun onViewInTimeline(eventId: EventId) {
|
||||
// Cannot happen
|
||||
}
|
||||
}
|
||||
mediaViewerEntryPoint.nodeBuilder(this, buildContext)
|
||||
.avatar(
|
||||
|
|
@ -222,10 +238,29 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
|||
.callback(callback)
|
||||
.build()
|
||||
}
|
||||
|
||||
is NavTarget.PollHistory -> {
|
||||
pollHistoryEntryPoint.createNode(this, buildContext)
|
||||
}
|
||||
is NavTarget.MediaGallery -> {
|
||||
val callback = object : MediaGalleryEntryPoint.Callback {
|
||||
override fun 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)
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ class RoomDetailsNode @AssistedInject constructor(
|
|||
fun openRoomNotificationSettings()
|
||||
fun openAvatarPreview(name: String, url: String)
|
||||
fun openPollHistory()
|
||||
fun openMediaGallery()
|
||||
fun openAdminSettings()
|
||||
fun openPinnedMessagesList()
|
||||
fun openKnockRequestsList()
|
||||
|
|
@ -77,6 +78,10 @@ class RoomDetailsNode @AssistedInject constructor(
|
|||
callbacks.forEach { it.openPollHistory() }
|
||||
}
|
||||
|
||||
private fun openMediaGallery() {
|
||||
callbacks.forEach { it.openMediaGallery() }
|
||||
}
|
||||
|
||||
private fun onJoinCall() {
|
||||
callbacks.forEach { it.onJoinCall() }
|
||||
}
|
||||
|
|
@ -143,6 +148,7 @@ class RoomDetailsNode @AssistedInject constructor(
|
|||
invitePeople = ::invitePeople,
|
||||
openAvatarPreview = ::openAvatarPreview,
|
||||
openPollHistory = ::openPollHistory,
|
||||
openMediaGallery = ::openMediaGallery,
|
||||
openAdminSettings = this::openAdminSettings,
|
||||
onJoinCallClick = ::onJoinCall,
|
||||
onPinnedMessagesClick = ::openPinnedMessages,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import androidx.compose.runtime.mutableStateOf
|
|||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import im.vector.app.features.analytics.plan.Interaction
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomEvent
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||
|
|
@ -79,6 +80,10 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
val isPublic by remember { derivedStateOf { roomInfo?.isPublic.orFalse() } }
|
||||
|
||||
val canShowPinnedMessages = isPinnedMessagesFeatureEnabled()
|
||||
var canShowMediaGallery by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
canShowMediaGallery = featureFlagService.isFeatureEnabled(FeatureFlags.MediaGallery)
|
||||
}
|
||||
val pinnedMessagesCount by remember { derivedStateOf { roomInfo?.pinnedEventIds?.size } }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
|
|
@ -162,6 +167,7 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
isPublic = isPublic,
|
||||
heroes = roomInfo?.heroes.orEmpty().toPersistentList(),
|
||||
canShowPinnedMessages = canShowPinnedMessages,
|
||||
canShowMediaGallery = canShowMediaGallery,
|
||||
pinnedMessagesCount = pinnedMessagesCount,
|
||||
canShowKnockRequests = canShowKnockRequests,
|
||||
knockRequestsCount = knockRequestsCount,
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ data class RoomDetailsState(
|
|||
val isPublic: Boolean,
|
||||
val heroes: ImmutableList<MatrixUser>,
|
||||
val canShowPinnedMessages: Boolean,
|
||||
val canShowMediaGallery: Boolean,
|
||||
val pinnedMessagesCount: Int?,
|
||||
val canShowKnockRequests: Boolean,
|
||||
val knockRequestsCount: Int?,
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ fun aRoomDetailsState(
|
|||
isPublic: Boolean = true,
|
||||
heroes: List<MatrixUser> = emptyList(),
|
||||
canShowPinnedMessages: Boolean = true,
|
||||
canShowMediaGallery: Boolean = true,
|
||||
pinnedMessagesCount: Int? = null,
|
||||
canShowKnockRequests: Boolean = false,
|
||||
knockRequestsCount: Int? = null,
|
||||
|
|
@ -126,6 +127,7 @@ fun aRoomDetailsState(
|
|||
isPublic = isPublic,
|
||||
heroes = heroes.toPersistentList(),
|
||||
canShowPinnedMessages = canShowPinnedMessages,
|
||||
canShowMediaGallery = canShowMediaGallery,
|
||||
pinnedMessagesCount = pinnedMessagesCount,
|
||||
canShowKnockRequests = canShowKnockRequests,
|
||||
knockRequestsCount = knockRequestsCount,
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ fun RoomDetailsView(
|
|||
invitePeople: () -> Unit,
|
||||
openAvatarPreview: (name: String, url: String) -> Unit,
|
||||
openPollHistory: () -> Unit,
|
||||
openMediaGallery: () -> Unit,
|
||||
openAdminSettings: () -> Unit,
|
||||
onJoinCallClick: () -> Unit,
|
||||
onPinnedMessagesClick: () -> Unit,
|
||||
|
|
@ -219,7 +220,11 @@ fun RoomDetailsView(
|
|||
PollsSection(
|
||||
openPollHistory = openPollHistory
|
||||
)
|
||||
|
||||
if (state.canShowMediaGallery) {
|
||||
MediaGallerySection(
|
||||
onClick = openMediaGallery
|
||||
)
|
||||
}
|
||||
if (state.isEncrypted) {
|
||||
SecuritySection()
|
||||
}
|
||||
|
|
@ -576,6 +581,19 @@ private fun PollsSection(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaGallerySection(
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
PreferenceCategory {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.screen_room_details_media_gallery_title)) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Image())),
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SecuritySection() {
|
||||
PreferenceCategory(title = stringResource(R.string.screen_room_details_security_title)) {
|
||||
|
|
@ -631,6 +649,7 @@ private fun ContentToPreview(state: RoomDetailsState) {
|
|||
invitePeople = {},
|
||||
openAvatarPreview = { _, _ -> },
|
||||
openPollHistory = {},
|
||||
openMediaGallery = {},
|
||||
openAdminSettings = {},
|
||||
onJoinCallClick = {},
|
||||
onPinnedMessagesClick = {},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import io.element.android.libraries.architecture.BaseFlowNode
|
|||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||
|
|
@ -82,6 +83,10 @@ class UserProfileFlowNode @AssistedInject constructor(
|
|||
override fun onDone() {
|
||||
backstack.pop()
|
||||
}
|
||||
|
||||
override fun onViewInTimeline(eventId: EventId) {
|
||||
// Cannot happen
|
||||
}
|
||||
}
|
||||
mediaViewerEntryPoint.nodeBuilder(this, buildContext)
|
||||
.avatar(
|
||||
|
|
|
|||
|
|
@ -61,3 +61,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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.core.preview
|
||||
|
||||
val loremIpsum = """
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut la
|
||||
bore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris
|
||||
nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate v
|
||||
elit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proide
|
||||
nt, sunt in culpa qui officia deserunt mollit anim id est laborum.
|
||||
""".trimIndent()
|
||||
|
|
@ -57,4 +57,6 @@ enum class AvatarSize(val dp: Dp) {
|
|||
|
||||
KnockRequestItem(52.dp),
|
||||
KnockRequestBanner(32.dp),
|
||||
|
||||
MediaSender(32.dp),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,4 +154,11 @@ enum class FeatureFlags(
|
|||
defaultValue = { true },
|
||||
isFinished = false,
|
||||
),
|
||||
MediaGallery(
|
||||
key = "feature.media_gallery",
|
||||
title = "Allow user to open the media gallery",
|
||||
description = null,
|
||||
defaultValue = { buildMeta -> buildMeta.buildType != BuildType.RELEASE },
|
||||
isFinished = false,
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,11 @@ interface MatrixRoom : Closeable {
|
|||
*/
|
||||
suspend fun pinnedEventsTimeline(): Result<Timeline>
|
||||
|
||||
/**
|
||||
* Create a new timeline for the media events of the room.
|
||||
*/
|
||||
suspend fun mediaTimeline(): Result<Timeline>
|
||||
|
||||
fun destroy()
|
||||
|
||||
suspend fun subscribeToSync()
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@ interface Timeline : AutoCloseable {
|
|||
enum class Mode {
|
||||
LIVE,
|
||||
FOCUSED_ON_EVENT,
|
||||
PINNED_EVENTS
|
||||
PINNED_EVENTS,
|
||||
MEDIA,
|
||||
}
|
||||
|
||||
val membershipChangeEventReceived: Flow<Unit>
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener
|
|||
import org.matrix.rustcomponents.sdk.RoomInfo
|
||||
import org.matrix.rustcomponents.sdk.RoomInfoListener
|
||||
import org.matrix.rustcomponents.sdk.RoomListItem
|
||||
import org.matrix.rustcomponents.sdk.RoomMessageEventMessageType
|
||||
import org.matrix.rustcomponents.sdk.TypingNotificationsListener
|
||||
import org.matrix.rustcomponents.sdk.UserPowerLevelUpdate
|
||||
import org.matrix.rustcomponents.sdk.WidgetCapabilities
|
||||
|
|
@ -223,6 +224,26 @@ class RustMatrixRoom(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun mediaTimeline(): Result<Timeline> {
|
||||
return runCatching {
|
||||
innerRoom.messageFilteredTimeline(
|
||||
internalIdPrefix = "MediaGallery_",
|
||||
allowedMessageTypes = listOf(
|
||||
RoomMessageEventMessageType.FILE,
|
||||
RoomMessageEventMessageType.IMAGE,
|
||||
RoomMessageEventMessageType.VIDEO,
|
||||
RoomMessageEventMessageType.AUDIO,
|
||||
)
|
||||
).let { inner ->
|
||||
createTimeline(inner, mode = Timeline.Mode.MEDIA)
|
||||
}
|
||||
}.onFailure {
|
||||
if (it is CancellationException) {
|
||||
throw it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun destroy() {
|
||||
roomCoroutineScope.cancel()
|
||||
liveTimeline.close()
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ class FakeMatrixRoom(
|
|||
private val getMembersResult: (Int) -> Result<List<RoomMember>> = { lambdaError() },
|
||||
private val timelineFocusedOnEventResult: (EventId) -> Result<Timeline> = { lambdaError() },
|
||||
private val pinnedEventsTimelineResult: () -> Result<Timeline> = { lambdaError() },
|
||||
private val mediaTimelineResult: () -> Result<Timeline> = { lambdaError() },
|
||||
private val setSendQueueEnabledLambda: (Boolean) -> Unit = { _: Boolean -> },
|
||||
private val saveComposerDraftLambda: (ComposerDraft) -> Result<Unit> = { _: ComposerDraft -> Result.success(Unit) },
|
||||
private val loadComposerDraftLambda: () -> Result<ComposerDraft?> = { Result.success<ComposerDraft?>(null) },
|
||||
|
|
@ -203,6 +204,10 @@ class FakeMatrixRoom(
|
|||
pinnedEventsTimelineResult()
|
||||
}
|
||||
|
||||
override suspend fun mediaTimeline(): Result<Timeline> = simulateLongTask {
|
||||
mediaTimelineResult()
|
||||
}
|
||||
|
||||
override suspend fun subscribeToSync() {
|
||||
subscribeToSyncLambda()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediaviewer.api
|
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
|
||||
interface MediaGalleryEntryPoint : FeatureEntryPoint {
|
||||
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
|
||||
|
||||
interface NodeBuilder {
|
||||
fun callback(callback: Callback): NodeBuilder
|
||||
fun build(): Node
|
||||
}
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onBackClick()
|
||||
fun onViewInTimeline(eventId: EventId)
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ package io.element.android.libraries.mediaviewer.api
|
|||
|
||||
import android.os.Parcelable
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
|
|
@ -18,11 +19,14 @@ data class MediaInfo(
|
|||
val mimeType: String,
|
||||
val formattedFileSize: String,
|
||||
val fileExtension: String,
|
||||
val senderId: UserId?,
|
||||
val senderName: String?,
|
||||
val senderAvatar: String?,
|
||||
val dateSent: String?,
|
||||
) : Parcelable
|
||||
|
||||
fun anImageMediaInfo(
|
||||
senderId: UserId? = UserId("@alice:server.org"),
|
||||
caption: String? = null,
|
||||
senderName: String? = null,
|
||||
dateSent: String? = null,
|
||||
|
|
@ -32,7 +36,9 @@ fun anImageMediaInfo(
|
|||
mimeType = MimeTypes.Jpeg,
|
||||
formattedFileSize = "4MB",
|
||||
fileExtension = "jpg",
|
||||
senderId = senderId,
|
||||
senderName = senderName,
|
||||
senderAvatar = null,
|
||||
dateSent = dateSent,
|
||||
)
|
||||
|
||||
|
|
@ -46,24 +52,31 @@ fun aVideoMediaInfo(
|
|||
mimeType = MimeTypes.Mp4,
|
||||
formattedFileSize = "14MB",
|
||||
fileExtension = "mp4",
|
||||
senderId = UserId("@alice:server.org"),
|
||||
senderName = senderName,
|
||||
senderAvatar = null,
|
||||
dateSent = dateSent,
|
||||
)
|
||||
|
||||
fun aPdfMediaInfo(
|
||||
filename: String = "a pdf file.pdf",
|
||||
caption: String? = null,
|
||||
senderName: String? = null,
|
||||
dateSent: String? = null,
|
||||
): MediaInfo = MediaInfo(
|
||||
filename = "a pdf file.pdf",
|
||||
caption = null,
|
||||
filename = filename,
|
||||
caption = caption,
|
||||
mimeType = MimeTypes.Pdf,
|
||||
formattedFileSize = "23MB",
|
||||
fileExtension = "pdf",
|
||||
senderId = UserId("@alice:server.org"),
|
||||
senderName = senderName,
|
||||
senderAvatar = null,
|
||||
dateSent = dateSent,
|
||||
)
|
||||
|
||||
fun anApkMediaInfo(
|
||||
senderId: UserId? = UserId("@alice:server.org"),
|
||||
senderName: String? = null,
|
||||
dateSent: String? = null,
|
||||
): MediaInfo = MediaInfo(
|
||||
|
|
@ -72,7 +85,9 @@ fun anApkMediaInfo(
|
|||
mimeType = MimeTypes.Apk,
|
||||
formattedFileSize = "50MB",
|
||||
fileExtension = "apk",
|
||||
senderId = senderId,
|
||||
senderName = senderName,
|
||||
senderAvatar = null,
|
||||
dateSent = dateSent,
|
||||
)
|
||||
|
||||
|
|
@ -85,6 +100,8 @@ fun anAudioMediaInfo(
|
|||
mimeType = MimeTypes.Mp3,
|
||||
formattedFileSize = "7MB",
|
||||
fileExtension = "mp3",
|
||||
senderId = UserId("@alice:server.org"),
|
||||
senderName = senderName,
|
||||
senderAvatar = null,
|
||||
dateSent = dateSent,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import com.bumble.appyx.core.node.Node
|
|||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
|
||||
interface MediaViewerEntryPoint : FeatureEntryPoint {
|
||||
|
|
@ -26,12 +27,15 @@ interface MediaViewerEntryPoint : FeatureEntryPoint {
|
|||
|
||||
interface Callback : Plugin {
|
||||
fun onDone()
|
||||
fun onViewInTimeline(eventId: EventId)
|
||||
}
|
||||
|
||||
data class Params(
|
||||
val eventId: EventId?,
|
||||
val mediaInfo: MediaInfo,
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val canShowInfo: Boolean,
|
||||
val canDownload: Boolean,
|
||||
val canShare: Boolean,
|
||||
) : NodeInputs
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediaviewer.impl
|
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint
|
||||
import io.element.android.libraries.mediaviewer.impl.gallery.root.MediaGalleryRootNode
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultMediaGalleryEntryPoint @Inject constructor() : MediaGalleryEntryPoint {
|
||||
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): MediaGalleryEntryPoint.NodeBuilder {
|
||||
val plugins = ArrayList<Plugin>()
|
||||
|
||||
return object : MediaGalleryEntryPoint.NodeBuilder {
|
||||
override fun callback(callback: MediaGalleryEntryPoint.Callback): MediaGalleryEntryPoint.NodeBuilder {
|
||||
plugins += callback
|
||||
return this
|
||||
}
|
||||
|
||||
override fun build(): Node {
|
||||
return parentNode.createNode<MediaGalleryRootNode>(buildContext, plugins)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import com.squareup.anvil.annotations.ContributesBinding
|
|||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||
|
|
@ -41,17 +42,21 @@ class DefaultMediaViewerEntryPoint @Inject constructor() : MediaViewerEntryPoint
|
|||
val mimeType = MimeTypes.Images
|
||||
return params(
|
||||
MediaViewerEntryPoint.Params(
|
||||
eventId = null,
|
||||
mediaInfo = MediaInfo(
|
||||
filename = filename,
|
||||
caption = null,
|
||||
mimeType = mimeType,
|
||||
formattedFileSize = "",
|
||||
fileExtension = "",
|
||||
senderId = UserId("@dummy:server.org"),
|
||||
senderName = null,
|
||||
senderAvatar = null,
|
||||
dateSent = null,
|
||||
),
|
||||
mediaSource = MediaSource(url = avatarUrl),
|
||||
thumbnailSource = null,
|
||||
canShowInfo = false,
|
||||
canDownload = false,
|
||||
canShare = false,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediaviewer.impl.details
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
|
||||
sealed interface MediaBottomSheetState {
|
||||
data object Hidden : MediaBottomSheetState
|
||||
|
||||
data class MediaDeleteConfirmationState(
|
||||
val eventId: EventId,
|
||||
val mediaInfo: MediaInfo,
|
||||
val thumbnailSource: MediaSource?,
|
||||
) : MediaBottomSheetState
|
||||
|
||||
data class MediaDetailsBottomSheetState(
|
||||
val eventId: EventId?,
|
||||
val canDelete: Boolean,
|
||||
val mediaInfo: MediaInfo,
|
||||
val thumbnailSource: MediaSource?,
|
||||
) : MediaBottomSheetState
|
||||
}
|
||||
|
|
@ -0,0 +1,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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediaviewer.impl.gallery
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
|
||||
sealed interface MediaGalleryEvents {
|
||||
data class ChangeMode(val mode: MediaGalleryMode) : MediaGalleryEvents
|
||||
data class LoadMore(val direction: Timeline.PaginationDirection) : MediaGalleryEvents
|
||||
data class Share(val mediaItem: MediaItem.Event) : MediaGalleryEvents
|
||||
data class SaveOnDisk(val mediaItem: MediaItem.Event) : MediaGalleryEvents
|
||||
data class OpenInfo(val mediaItem: MediaItem.Event) : MediaGalleryEvents
|
||||
data class ViewInTimeline(val eventId: EventId) : MediaGalleryEvents
|
||||
|
||||
data class ConfirmDelete(
|
||||
val eventId: EventId,
|
||||
val mediaInfo: MediaInfo,
|
||||
val thumbnailSource: MediaSource?,
|
||||
) : MediaGalleryEvents
|
||||
|
||||
data object CloseBottomSheet : MediaGalleryEvents
|
||||
data class Delete(val eventId: EventId) : MediaGalleryEvents
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediaviewer.impl.gallery
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
|
||||
interface MediaGalleryNavigator {
|
||||
fun onViewInTimelineClick(eventId: EventId)
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediaviewer.impl.gallery
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.core.plugin.plugins
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
|
||||
@ContributesNode(RoomScope::class)
|
||||
class MediaGalleryNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
presenterFactory: MediaGalleryPresenter.Factory,
|
||||
) : Node(buildContext, plugins = plugins),
|
||||
MediaGalleryNavigator {
|
||||
private val presenter = presenterFactory.create(
|
||||
navigator = this,
|
||||
)
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediaviewer.impl.gallery
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
|
||||
sealed interface MediaItem {
|
||||
data class DateSeparator(
|
||||
val id: UniqueId,
|
||||
val formattedDate: String,
|
||||
) : MediaItem
|
||||
|
||||
data class LoadingIndicator(
|
||||
val id: UniqueId,
|
||||
val direction: Timeline.PaginationDirection,
|
||||
val timestamp: Long,
|
||||
) : MediaItem
|
||||
|
||||
sealed interface Event : MediaItem
|
||||
|
||||
data class Image(
|
||||
val id: UniqueId,
|
||||
val eventId: EventId?,
|
||||
val mediaInfo: MediaInfo,
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
) : Event {
|
||||
val thumbnailMediaRequestData: MediaRequestData
|
||||
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
|
||||
}
|
||||
|
||||
data class Video(
|
||||
val id: UniqueId,
|
||||
val eventId: EventId?,
|
||||
val mediaInfo: MediaInfo,
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val duration: String?,
|
||||
) : Event {
|
||||
val thumbnailMediaRequestData: MediaRequestData
|
||||
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
|
||||
}
|
||||
|
||||
data class File(
|
||||
val id: UniqueId,
|
||||
val eventId: EventId?,
|
||||
val mediaInfo: MediaInfo,
|
||||
val mediaSource: MediaSource,
|
||||
) : Event
|
||||
}
|
||||
|
||||
fun MediaItem.id(): UniqueId {
|
||||
return when (this) {
|
||||
is MediaItem.DateSeparator -> id
|
||||
is MediaItem.LoadingIndicator -> id
|
||||
is MediaItem.Image -> id
|
||||
is MediaItem.Video -> id
|
||||
is MediaItem.File -> id
|
||||
}
|
||||
}
|
||||
|
||||
fun MediaItem.Event.eventId(): EventId? {
|
||||
return when (this) {
|
||||
is MediaItem.Image -> eventId
|
||||
is MediaItem.Video -> eventId
|
||||
is MediaItem.File -> eventId
|
||||
}
|
||||
}
|
||||
|
||||
fun MediaItem.Event.mediaInfo(): MediaInfo {
|
||||
return when (this) {
|
||||
is MediaItem.Image -> mediaInfo
|
||||
is MediaItem.Video -> mediaInfo
|
||||
is MediaItem.File -> mediaInfo
|
||||
}
|
||||
}
|
||||
|
||||
fun MediaItem.Event.mediaSource(): MediaSource {
|
||||
return when (this) {
|
||||
is MediaItem.Image -> mediaSource
|
||||
is MediaItem.Video -> mediaSource
|
||||
is MediaItem.File -> mediaSource
|
||||
}
|
||||
}
|
||||
|
||||
fun MediaItem.Event.thumbnailSource(): MediaSource? {
|
||||
return when (this) {
|
||||
is MediaItem.Image -> thumbnailSource
|
||||
is MediaItem.Video -> thumbnailSource
|
||||
is MediaItem.File -> null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediaviewer.impl.gallery.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.core.extensions.withBrackets
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
|
||||
|
||||
@Composable
|
||||
fun FileItemView(
|
||||
file: MediaItem.File,
|
||||
onClick: () -> Unit,
|
||||
onShareClick: () -> Unit,
|
||||
onDownloadClick: () -> Unit,
|
||||
onInfoClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 20.dp, start = 16.dp, end = 16.dp),
|
||||
) {
|
||||
FilenameRow(
|
||||
file = file,
|
||||
onClick = onClick,
|
||||
)
|
||||
val caption = file.mediaInfo.caption
|
||||
if (caption != null) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Caption(caption)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
ActionIconsRow(
|
||||
onShareClick = onShareClick,
|
||||
onDownloadClick = onDownloadClick,
|
||||
onInfoClick = onInfoClick,
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilenameRow(
|
||||
file: MediaItem.File,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
color = ElementTheme.colors.bgSubtleSecondary,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
.clickable { onClick() }
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = ElementTheme.colors.bgActionSecondaryRest,
|
||||
shape = CircleShape,
|
||||
)
|
||||
.size(32.dp)
|
||||
.padding(6.dp),
|
||||
imageVector = CompoundIcons.Attachment(),
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = file.mediaInfo.filename,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val formattedSize = file.mediaInfo.formattedFileSize
|
||||
if (formattedSize.isNotEmpty()) {
|
||||
Text(
|
||||
text = formattedSize.withBrackets(),
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Caption(caption: String) {
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = caption,
|
||||
maxLines = 5,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionIconsRow(
|
||||
onShareClick: () -> Unit,
|
||||
onDownloadClick: () -> Unit,
|
||||
onInfoClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onShareClick,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.ShareAndroid(),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = onDownloadClick,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Download(),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = onInfoClick,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Info(),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun FileItemViewPreview(
|
||||
@PreviewParameter(MediaItemFileProvider::class) file: MediaItem.File,
|
||||
) = ElementPreview {
|
||||
FileItemView(
|
||||
file = file,
|
||||
onClick = {},
|
||||
onShareClick = {},
|
||||
onDownloadClick = {},
|
||||
onInfoClick = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediaviewer.impl.gallery.ui
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ImageItemView(
|
||||
image: MediaItem.Image,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val bgColor = if (LocalInspectionMode.current) {
|
||||
ElementTheme.colors.bgDecorative1
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
Box(
|
||||
modifier = modifier
|
||||
.aspectRatio(1f)
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||
.background(bgColor),
|
||||
) {
|
||||
var isLoaded by remember { mutableStateOf(false) }
|
||||
AsyncImage(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (isLoaded) Modifier.background(Color.White) else Modifier),
|
||||
model = image.thumbnailMediaRequestData,
|
||||
contentScale = ContentScale.Crop,
|
||||
alignment = Alignment.Center,
|
||||
contentDescription = null,
|
||||
onState = { isLoaded = it is AsyncImagePainter.State.Success },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun ImageItemViewPreview() = ElementPreview {
|
||||
ImageItemView(
|
||||
image = aMediaItemImage(),
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediaviewer.impl.gallery.ui
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
|
||||
|
||||
class MediaItemDateSeparatorProvider : PreviewParameterProvider<MediaItem.DateSeparator> {
|
||||
override val values: Sequence<MediaItem.DateSeparator>
|
||||
get() = sequenceOf(
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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(""),
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediaviewer.impl.gallery.ui
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun VideoItemView(
|
||||
video: MediaItem.Video,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val bgColor = if (LocalInspectionMode.current) {
|
||||
ElementTheme.colors.bgDecorative2
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
Box(
|
||||
modifier = modifier
|
||||
.aspectRatio(1f)
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||
.background(bgColor),
|
||||
) {
|
||||
var isLoaded by remember { mutableStateOf(false) }
|
||||
AsyncImage(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (isLoaded) Modifier.background(Color.White) else Modifier),
|
||||
model = video.thumbnailMediaRequestData,
|
||||
contentScale = ContentScale.Crop,
|
||||
alignment = Alignment.Center,
|
||||
contentDescription = null,
|
||||
onState = { isLoaded = it is AsyncImagePainter.State.Success },
|
||||
)
|
||||
VideoInfoRow(
|
||||
video = video,
|
||||
modifier = Modifier.align(Alignment.BottomStart)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VideoInfoRow(
|
||||
video: MediaItem.Video,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(20.dp),
|
||||
imageVector = CompoundIcons.VideoCallSolid(),
|
||||
contentDescription = null
|
||||
)
|
||||
if (video.duration != null) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
text = video.duration,
|
||||
style = ElementTheme.typography.fontBodySmMedium,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun VideoItemViewPreview(
|
||||
@PreviewParameter(MediaItemVideoProvider::class) video: MediaItem.Video,
|
||||
) = ElementPreview {
|
||||
VideoItemView(
|
||||
video = video,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
|
|||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.media.MediaFile
|
||||
import io.element.android.libraries.matrix.api.media.toFile
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
|
|
@ -41,7 +42,9 @@ class AndroidLocalMediaFactory @Inject constructor(
|
|||
name = mediaInfo.filename,
|
||||
caption = mediaInfo.caption,
|
||||
formattedFileSize = mediaInfo.formattedFileSize,
|
||||
senderId = mediaInfo.senderId,
|
||||
senderName = mediaInfo.senderName,
|
||||
senderAvatar = mediaInfo.senderAvatar,
|
||||
dateSent = mediaInfo.dateSent,
|
||||
)
|
||||
|
||||
|
|
@ -56,7 +59,9 @@ class AndroidLocalMediaFactory @Inject constructor(
|
|||
name = name,
|
||||
caption = null,
|
||||
formattedFileSize = formattedFileSize,
|
||||
senderId = null,
|
||||
senderName = null,
|
||||
senderAvatar = null,
|
||||
dateSent = null,
|
||||
)
|
||||
|
||||
|
|
@ -66,7 +71,9 @@ class AndroidLocalMediaFactory @Inject constructor(
|
|||
name: String?,
|
||||
caption: String?,
|
||||
formattedFileSize: String?,
|
||||
senderId: UserId?,
|
||||
senderName: String?,
|
||||
senderAvatar: String?,
|
||||
dateSent: String?,
|
||||
): LocalMedia {
|
||||
val resolvedMimeType = mimeType ?: context.getMimeType(uri) ?: MimeTypes.OctetStream
|
||||
|
|
@ -81,7 +88,9 @@ class AndroidLocalMediaFactory @Inject constructor(
|
|||
caption = caption,
|
||||
formattedFileSize = fileSize,
|
||||
fileExtension = fileExtension,
|
||||
senderId = senderId,
|
||||
senderName = senderName,
|
||||
senderAvatar = senderAvatar,
|
||||
dateSent = dateSent,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,10 +7,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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediaviewer.impl.viewer
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
|
||||
interface MediaViewerNavigator {
|
||||
fun onViewInTimelineClick(eventId: EventId)
|
||||
fun onItemDeleted()
|
||||
}
|
||||
|
|
@ -19,14 +19,16 @@ import io.element.android.anvilannotations.ContributesNode
|
|||
import io.element.android.compound.theme.ForcedDarkElementTheme
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||
|
||||
@ContributesNode(RoomScope::class)
|
||||
open class MediaViewerNode @AssistedInject constructor(
|
||||
class MediaViewerNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
presenterFactory: MediaViewerPresenter.Factory,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
) : Node(buildContext, plugins = plugins),
|
||||
MediaViewerNavigator {
|
||||
private val inputs = inputs<MediaViewerEntryPoint.Params>()
|
||||
|
||||
private fun onDone() {
|
||||
|
|
@ -35,7 +37,20 @@ open class MediaViewerNode @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private val presenter = presenterFactory.create(inputs)
|
||||
override fun onViewInTimelineClick(eventId: EventId) {
|
||||
plugins<MediaViewerEntryPoint.Callback>().forEach {
|
||||
it.onViewInTimeline(eventId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemDeleted() {
|
||||
onDone()
|
||||
}
|
||||
|
||||
private val presenter = presenterFactory.create(
|
||||
inputs = inputs,
|
||||
navigator = this,
|
||||
)
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 n’y 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 n’a 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
18
libraries/mediaviewer/impl/src/main/res/values/localazy.xml
Normal file
18
libraries/mediaviewer/impl/src/main/res/values/localazy.xml
Normal 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 won’t have access to it."</string>
|
||||
<string name="screen_media_browser_delete_confirmation_title">"Delete file?"</string>
|
||||
<string name="screen_media_browser_empty_state_subtitle">"Images and videos uploaded to this room will be shown here."</string>
|
||||
<string name="screen_media_browser_empty_state_title">"No media uploaded yet"</string>
|
||||
<string name="screen_media_browser_list_loading_files">"Loading files…"</string>
|
||||
<string name="screen_media_browser_list_loading_media">"Loading media…"</string>
|
||||
<string name="screen_media_browser_list_mode_files">"Files"</string>
|
||||
<string name="screen_media_browser_list_mode_media">"Media"</string>
|
||||
<string name="screen_media_browser_title">"Media and files"</string>
|
||||
<string name="screen_media_details_file_format">"File format"</string>
|
||||
<string name="screen_media_details_filename">"File name"</string>
|
||||
<string name="screen_media_details_redact_confirmation_message">"This file will be removed from the room and members won’t 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>
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediaviewer.impl.gallery
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
|
||||
class FakeMediaGalleryNavigator(
|
||||
private val onViewInTimelineClickLambda: (EventId) -> Unit = { lambdaError() }
|
||||
) : MediaGalleryNavigator {
|
||||
override fun onViewInTimelineClick(eventId: EventId) {
|
||||
onViewInTimelineClickLambda(eventId)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediaviewer.impl.viewer
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
|
||||
class FakeMediaViewerNavigator(
|
||||
private val onViewInTimelineClickLambda: (EventId) -> Unit = { lambdaError() },
|
||||
private val onItemDeletedLambda: () -> Unit = { lambdaError() },
|
||||
) : MediaViewerNavigator {
|
||||
override fun onViewInTimelineClick(eventId: EventId) {
|
||||
onViewInTimelineClickLambda(eventId)
|
||||
}
|
||||
|
||||
override fun onItemDeleted() {
|
||||
onItemDeletedLambda()
|
||||
}
|
||||
}
|
||||
|
|
@ -16,20 +16,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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 n’y 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 n’a 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 d’anciennes 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 n’a pas été envoyé car vous n’avez 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 l’utilisateur"</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 d’adhésion envoyée"</string>
|
||||
<string name="screen_share_location_title">"Partage de position"</string>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 won’t have access to it."</string>
|
||||
<string name="screen_media_browser_delete_confirmation_title">"Delete file?"</string>
|
||||
<string name="screen_media_browser_empty_state_subtitle">"Images and videos uploaded to this room will be shown here."</string>
|
||||
<string name="screen_media_browser_empty_state_title">"No media uploaded yet"</string>
|
||||
<string name="screen_media_browser_list_loading_files">"Loading files…"</string>
|
||||
<string name="screen_media_browser_list_loading_media">"Loading media…"</string>
|
||||
<string name="screen_media_browser_list_mode_files">"Files"</string>
|
||||
<string name="screen_media_browser_list_mode_media">"Media"</string>
|
||||
<string name="screen_media_browser_title">"Media and files"</string>
|
||||
<string name="screen_media_details_file_format">"File format"</string>
|
||||
<string name="screen_media_details_filename">"File name"</string>
|
||||
<string name="screen_media_details_uploaded_by">"Uploaded by"</string>
|
||||
<string name="screen_media_details_uploaded_on">"Uploaded on"</string>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:94f040a3d18493f80b5f90eb48e68c664de5ddee0ae4575905ce35709d31abe9
|
||||
size 40969
|
||||
oid sha256:c7828106cd3724769c5bbeaad50c3417264abcb6af40ffd90aee283e8b29e579
|
||||
size 41831
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:96cf72bdceae29a86593ed3bd02d5edbe1f5422e5be0798f536b49805088b0b8
|
||||
size 45109
|
||||
oid sha256:f9c24abc59ed8ef26f647b5d3855b768af6043bdbb035f0d2756a7b783f64561
|
||||
size 39848
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0c719ba2c0782ecf8ebf37c07dbc79d37b1d993e4987388ceafbefb31b03d100
|
||||
size 44064
|
||||
oid sha256:b87d165479dd2a0d6497fdac37bb43de760fd0eade06ad23b53baff667b8af03
|
||||
size 38783
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a675afee3fcef0f8468ec93e33e1e86398bda517f4f54615aaf527d549387431
|
||||
size 47217
|
||||
oid sha256:2e7726872c78a2bcddfefa689699d7bf69a09b55c023bbc7008575fbec5b7779
|
||||
size 41986
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ced35352da8f7b6c9d4a5647cf1ff29f194d4f68ca9eec9c268ab889271e4776
|
||||
size 45507
|
||||
oid sha256:6235b9e9b6ffb9e4d813cfa49c9819a3cbc112d6dc5d25d7901a257e4353e609
|
||||
size 40241
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3444cc70e1f1b212d89ba404199a439a498281aa9faff9a9bb2469b727498224
|
||||
size 37486
|
||||
oid sha256:75bcd07324f5acb45552d8d5bbe369cece798d109f3c096859c6a88fccc8e2e1
|
||||
size 39797
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue