Skip to content

Commit

Permalink
Merge pull request #4161 from element-hq/feature/bma/mediaNavigation
Browse files Browse the repository at this point in the history
Media navigation with swipe gesture
  • Loading branch information
bmarty authored Jan 23, 2025
2 parents 0b04e40 + da22758 commit 3668e86
Show file tree
Hide file tree
Showing 76 changed files with 2,630 additions and 668 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.duration
import io.element.android.features.poll.api.create.CreatePollEntryPoint
import io.element.android.features.poll.api.create.CreatePollMode
import io.element.android.libraries.architecture.BackstackWithOverlayBox
Expand All @@ -58,6 +59,7 @@ import io.element.android.libraries.architecture.overlay.operation.hide
import io.element.android.libraries.architecture.overlay.operation.show
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.dateformatter.api.toHumanReadableDuration
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
Expand Down Expand Up @@ -246,6 +248,8 @@ class MessagesFlowNode @AssistedInject constructor(
}
is NavTarget.MediaViewer -> {
val params = MediaViewerEntryPoint.Params(
// TODO When we will be able to load a media timeline from a EventId, change mode here (and use a mixed mode?)
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
eventId = navTarget.eventId,
mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource,
Expand Down Expand Up @@ -447,6 +451,7 @@ class MessagesFlowNode @AssistedInject constructor(
mode = DateFormatterMode.Full,
),
waveform = (content as? TimelineItemVoiceContent)?.waveform,
duration = content.duration()?.toHumanReadableDuration(),
),
mediaSource = mediaSource,
thumbnailSource = thumbnailSource,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ package io.element.android.features.messages.impl.timeline.model.event

import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.media.MediaSource
import kotlin.time.Duration

@Immutable
sealed interface TimelineItemEventContent {
Expand Down Expand Up @@ -90,3 +91,12 @@ fun TimelineItemEventContent.isEdited(): Boolean = when (this) {
is TimelineItemEventMutableContent -> isEdited
else -> false
}

fun TimelineItemEventContentWithAttachment.duration(): Duration? {
return when (this) {
is TimelineItemAudioContent -> duration
is TimelineItemVideoContent -> duration
is TimelineItemVoiceContent -> duration
else -> null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
package io.element.android.libraries.dateformatter.api

import java.util.Locale
import kotlin.time.Duration

/**
* Convert milliseconds to human readable duration.
Expand Down Expand Up @@ -38,3 +39,5 @@ fun Long.toHumanReadableDuration(): String {
String.format(Locale.US, "%d:%02d", minutes, seconds)
}
}

fun Duration.toHumanReadableDuration() = inWholeMilliseconds.toHumanReadableDuration()
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow
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
Expand Down Expand Up @@ -213,8 +211,8 @@ class RustTimeline(

override val timelineItems: Flow<List<MatrixTimelineItem>> = combine(
_timelineItems,
backPaginationStatus.filter { !it.isPaginating }.distinctUntilChanged(),
forwardPaginationStatus.filter { !it.isPaginating }.distinctUntilChanged(),
backPaginationStatus,
forwardPaginationStatus,
matrixRoom.roomInfoFlow.map { it.creator },
isTimelineInitialized,
) { timelineItems,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
package io.element.android.libraries.matrix.impl.fixtures.fakes

import org.matrix.rustcomponents.sdk.NoPointer
import org.matrix.rustcomponents.sdk.PaginationStatusListener
import org.matrix.rustcomponents.sdk.TaskHandle
import org.matrix.rustcomponents.sdk.Timeline
import org.matrix.rustcomponents.sdk.TimelineDiff
import org.matrix.rustcomponents.sdk.TimelineListener
import uniffi.matrix_sdk_ui.LiveBackPaginationStatus

class FakeRustTimeline : Timeline(NoPointer) {
private var listener: TimelineListener? = null
Expand All @@ -23,4 +25,16 @@ class FakeRustTimeline : Timeline(NoPointer) {
fun emitDiff(diff: List<TimelineDiff>) {
listener!!.onUpdate(diff)
}

private var paginationStatusListener: PaginationStatusListener? = null
override suspend fun subscribeToBackPaginationStatus(listener: PaginationStatusListener): TaskHandle {
this.paginationStatusListener = listener
return FakeRustTaskHandle()
}

fun emitPaginationStatus(status: LiveBackPaginationStatus) {
paginationStatusListener!!.onUpdate(status)
}

override suspend fun fetchMembers() = Unit
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)

package io.element.android.libraries.matrix.impl.timeline

import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeRustRoomListService
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeRustTimeline
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeRustTimelineDiff
import io.element.android.libraries.matrix.impl.room.RoomContentForwarder
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.services.toolbox.api.systemclock.SystemClock
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.matrix.rustcomponents.sdk.TimelineChange
import uniffi.matrix_sdk_ui.LiveBackPaginationStatus
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline

class RustTimelineTest {
@Test
fun `ensure that the timeline emits new loading item when pagination does not bring new events`() = runTest {
val inner = FakeRustTimeline()
val systemClock = FakeSystemClock()
val sut = createRustTimeline(
inner = inner,
systemClock = systemClock,
)
sut.timelineItems.test {
// Give time for the listener to be set
runCurrent()
inner.emitDiff(
listOf(
FakeRustTimelineDiff(
item = null,
change = TimelineChange.RESET,
)
)
)
with(awaitItem()) {
assertThat(size).isEqualTo(1)
// Typing notification
assertThat((get(0) as MatrixTimelineItem.Virtual).virtual).isEqualTo(VirtualTimelineItem.TypingNotification)
}
with(awaitItem()) {
assertThat(size).isEqualTo(2)
// The loading
assertThat((get(0) as MatrixTimelineItem.Virtual).virtual).isEqualTo(
VirtualTimelineItem.LoadingIndicator(
direction = Timeline.PaginationDirection.BACKWARDS,
timestamp = A_FAKE_TIMESTAMP,
)
)
// Typing notification
assertThat((get(1) as MatrixTimelineItem.Virtual).virtual).isEqualTo(VirtualTimelineItem.TypingNotification)
}
systemClock.epochMillisResult = A_FAKE_TIMESTAMP + 1
// Start pagination
sut.paginate(Timeline.PaginationDirection.BACKWARDS)
// Simulate SDK starting pagination
inner.emitPaginationStatus(LiveBackPaginationStatus.Paginating)
// No new events received
// Simulate SDK stopping pagination, more event to load
inner.emitPaginationStatus(LiveBackPaginationStatus.Idle(hitStartOfTimeline = false))
// expect an item to be emitted, with an updated timestamp
with(awaitItem()) {
assertThat(size).isEqualTo(2)
// The loading
assertThat((get(0) as MatrixTimelineItem.Virtual).virtual).isEqualTo(
VirtualTimelineItem.LoadingIndicator(
direction = Timeline.PaginationDirection.BACKWARDS,
timestamp = A_FAKE_TIMESTAMP + 1,
)
)
// Typing notification
assertThat((get(1) as MatrixTimelineItem.Virtual).virtual).isEqualTo(VirtualTimelineItem.TypingNotification)
}
}
}
}

private fun TestScope.createRustTimeline(
inner: InnerTimeline,
mode: Timeline.Mode = Timeline.Mode.LIVE,
systemClock: SystemClock = FakeSystemClock(),
matrixRoom: MatrixRoom = FakeMatrixRoom().apply { givenRoomInfo(aRoomInfo()) },
coroutineScope: CoroutineScope = backgroundScope,
dispatcher: CoroutineDispatcher = testCoroutineDispatchers().io,
roomContentForwarder: RoomContentForwarder = RoomContentForwarder(FakeRustRoomListService()),
featureFlagsService: FeatureFlagService = FakeFeatureFlagService(),
onNewSyncedEvent: () -> Unit = {},
): RustTimeline {
return RustTimeline(
inner = inner,
mode = mode,
systemClock = systemClock,
matrixRoom = matrixRoom,
coroutineScope = coroutineScope,
dispatcher = dispatcher,
roomContentForwarder = roomContentForwarder,
featureFlagsService = featureFlagsService,
onNewSyncedEvent = onNewSyncedEvent,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ data class MediaInfo(
val dateSent: String?,
val dateSentFull: String?,
val waveform: List<Float>?,
val duration: String?,
) : Parcelable

fun anImageMediaInfo(
Expand All @@ -45,13 +46,15 @@ fun anImageMediaInfo(
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = null,
duration = null,
)

fun aVideoMediaInfo(
caption: String? = null,
senderName: String? = null,
dateSent: String? = null,
dateSentFull: String? = null,
duration: String? = null,
): MediaInfo = MediaInfo(
filename = "a video file.mp4",
caption = caption,
Expand All @@ -64,6 +67,7 @@ fun aVideoMediaInfo(
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = null,
duration = duration,
)

fun aPdfMediaInfo(
Expand All @@ -84,6 +88,7 @@ fun aPdfMediaInfo(
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = null,
duration = null,
)

fun anApkMediaInfo(
Expand All @@ -103,6 +108,7 @@ fun anApkMediaInfo(
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = null,
duration = null,
)

fun anAudioMediaInfo(
Expand All @@ -112,6 +118,7 @@ fun anAudioMediaInfo(
dateSent: String? = null,
dateSentFull: String? = null,
waveForm: List<Float>? = null,
duration: String? = null,
): MediaInfo = MediaInfo(
filename = filename,
caption = caption,
Expand All @@ -124,6 +131,7 @@ fun anAudioMediaInfo(
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = waveForm,
duration = duration,
)

fun aVoiceMediaInfo(
Expand All @@ -133,6 +141,7 @@ fun aVoiceMediaInfo(
dateSent: String? = null,
dateSentFull: String? = null,
waveForm: List<Float>? = null,
duration: String? = null,
): MediaInfo = MediaInfo(
filename = filename,
caption = caption,
Expand All @@ -145,4 +154,5 @@ fun aVoiceMediaInfo(
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = waveForm,
duration = duration,
)
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,17 @@ interface MediaViewerEntryPoint : FeatureEntryPoint {
}

data class Params(
val mode: MediaViewerMode,
val eventId: EventId?,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val canShowInfo: Boolean,
) : NodeInputs

enum class MediaViewerMode {
SingleMedia,
TimelineImagesAndVideos,
TimelineFilesAndAudios,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class DefaultMediaViewerEntryPoint @Inject constructor() : MediaViewerEntryPoint
val mimeType = MimeTypes.Images
return params(
MediaViewerEntryPoint.Params(
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
eventId = null,
mediaInfo = MediaInfo(
filename = filename,
Expand All @@ -55,6 +56,7 @@ class DefaultMediaViewerEntryPoint @Inject constructor() : MediaViewerEntryPoint
dateSent = null,
dateSentFull = null,
waveform = null,
duration = null,
),
mediaSource = MediaSource(url = avatarUrl),
thumbnailSource = null,
Expand Down
Loading

0 comments on commit 3668e86

Please sign in to comment.