Skip to content

Commit 3668e86

Browse files
authored
Merge pull request #4161 from element-hq/feature/bma/mediaNavigation
Media navigation with swipe gesture
2 parents 0b04e40 + da22758 commit 3668e86

File tree

76 files changed

+2630
-668
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

76 files changed

+2630
-668
lines changed

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
4848
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
4949
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
5050
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
51+
import io.element.android.features.messages.impl.timeline.model.event.duration
5152
import io.element.android.features.poll.api.create.CreatePollEntryPoint
5253
import io.element.android.features.poll.api.create.CreatePollMode
5354
import io.element.android.libraries.architecture.BackstackWithOverlayBox
@@ -58,6 +59,7 @@ import io.element.android.libraries.architecture.overlay.operation.hide
5859
import io.element.android.libraries.architecture.overlay.operation.show
5960
import io.element.android.libraries.dateformatter.api.DateFormatter
6061
import io.element.android.libraries.dateformatter.api.DateFormatterMode
62+
import io.element.android.libraries.dateformatter.api.toHumanReadableDuration
6163
import io.element.android.libraries.di.RoomScope
6264
import io.element.android.libraries.matrix.api.MatrixClient
6365
import io.element.android.libraries.matrix.api.core.EventId
@@ -246,6 +248,8 @@ class MessagesFlowNode @AssistedInject constructor(
246248
}
247249
is NavTarget.MediaViewer -> {
248250
val params = MediaViewerEntryPoint.Params(
251+
// TODO When we will be able to load a media timeline from a EventId, change mode here (and use a mixed mode?)
252+
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
249253
eventId = navTarget.eventId,
250254
mediaInfo = navTarget.mediaInfo,
251255
mediaSource = navTarget.mediaSource,
@@ -447,6 +451,7 @@ class MessagesFlowNode @AssistedInject constructor(
447451
mode = DateFormatterMode.Full,
448452
),
449453
waveform = (content as? TimelineItemVoiceContent)?.waveform,
454+
duration = content.duration()?.toHumanReadableDuration(),
450455
),
451456
mediaSource = mediaSource,
452457
thumbnailSource = thumbnailSource,

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ package io.element.android.features.messages.impl.timeline.model.event
99

1010
import androidx.compose.runtime.Immutable
1111
import io.element.android.libraries.matrix.api.media.MediaSource
12+
import kotlin.time.Duration
1213

1314
@Immutable
1415
sealed interface TimelineItemEventContent {
@@ -90,3 +91,12 @@ fun TimelineItemEventContent.isEdited(): Boolean = when (this) {
9091
is TimelineItemEventMutableContent -> isEdited
9192
else -> false
9293
}
94+
95+
fun TimelineItemEventContentWithAttachment.duration(): Duration? {
96+
return when (this) {
97+
is TimelineItemAudioContent -> duration
98+
is TimelineItemVideoContent -> duration
99+
is TimelineItemVoiceContent -> duration
100+
else -> null
101+
}
102+
}

libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatter.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
package io.element.android.libraries.dateformatter.api
99

1010
import java.util.Locale
11+
import kotlin.time.Duration
1112

1213
/**
1314
* Convert milliseconds to human readable duration.
@@ -38,3 +39,5 @@ fun Long.toHumanReadableDuration(): String {
3839
String.format(Locale.US, "%d:%02d", minutes, seconds)
3940
}
4041
}
42+
43+
fun Duration.toHumanReadableDuration() = inWholeMilliseconds.toHumanReadableDuration()

libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow
5555
import kotlinx.coroutines.flow.MutableStateFlow
5656
import kotlinx.coroutines.flow.StateFlow
5757
import kotlinx.coroutines.flow.combine
58-
import kotlinx.coroutines.flow.distinctUntilChanged
59-
import kotlinx.coroutines.flow.filter
6058
import kotlinx.coroutines.flow.first
6159
import kotlinx.coroutines.flow.getAndUpdate
6260
import kotlinx.coroutines.flow.launchIn
@@ -213,8 +211,8 @@ class RustTimeline(
213211

214212
override val timelineItems: Flow<List<MatrixTimelineItem>> = combine(
215213
_timelineItems,
216-
backPaginationStatus.filter { !it.isPaginating }.distinctUntilChanged(),
217-
forwardPaginationStatus.filter { !it.isPaginating }.distinctUntilChanged(),
214+
backPaginationStatus,
215+
forwardPaginationStatus,
218216
matrixRoom.roomInfoFlow.map { it.creator },
219217
isTimelineInitialized,
220218
) { timelineItems,

libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustTimeline.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
package io.element.android.libraries.matrix.impl.fixtures.fakes
99

1010
import org.matrix.rustcomponents.sdk.NoPointer
11+
import org.matrix.rustcomponents.sdk.PaginationStatusListener
1112
import org.matrix.rustcomponents.sdk.TaskHandle
1213
import org.matrix.rustcomponents.sdk.Timeline
1314
import org.matrix.rustcomponents.sdk.TimelineDiff
1415
import org.matrix.rustcomponents.sdk.TimelineListener
16+
import uniffi.matrix_sdk_ui.LiveBackPaginationStatus
1517

1618
class FakeRustTimeline : Timeline(NoPointer) {
1719
private var listener: TimelineListener? = null
@@ -23,4 +25,16 @@ class FakeRustTimeline : Timeline(NoPointer) {
2325
fun emitDiff(diff: List<TimelineDiff>) {
2426
listener!!.onUpdate(diff)
2527
}
28+
29+
private var paginationStatusListener: PaginationStatusListener? = null
30+
override suspend fun subscribeToBackPaginationStatus(listener: PaginationStatusListener): TaskHandle {
31+
this.paginationStatusListener = listener
32+
return FakeRustTaskHandle()
33+
}
34+
35+
fun emitPaginationStatus(status: LiveBackPaginationStatus) {
36+
paginationStatusListener!!.onUpdate(status)
37+
}
38+
39+
override suspend fun fetchMembers() = Unit
2640
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
@file:OptIn(ExperimentalCoroutinesApi::class)
8+
9+
package io.element.android.libraries.matrix.impl.timeline
10+
11+
import app.cash.turbine.test
12+
import com.google.common.truth.Truth.assertThat
13+
import io.element.android.libraries.featureflag.api.FeatureFlagService
14+
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
15+
import io.element.android.libraries.matrix.api.room.MatrixRoom
16+
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
17+
import io.element.android.libraries.matrix.api.timeline.Timeline
18+
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
19+
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeRustRoomListService
20+
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeRustTimeline
21+
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeRustTimelineDiff
22+
import io.element.android.libraries.matrix.impl.room.RoomContentForwarder
23+
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
24+
import io.element.android.libraries.matrix.test.room.aRoomInfo
25+
import io.element.android.services.toolbox.api.systemclock.SystemClock
26+
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
27+
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
28+
import io.element.android.tests.testutils.testCoroutineDispatchers
29+
import kotlinx.coroutines.CoroutineDispatcher
30+
import kotlinx.coroutines.CoroutineScope
31+
import kotlinx.coroutines.ExperimentalCoroutinesApi
32+
import kotlinx.coroutines.test.TestScope
33+
import kotlinx.coroutines.test.runCurrent
34+
import kotlinx.coroutines.test.runTest
35+
import org.junit.Test
36+
import org.matrix.rustcomponents.sdk.TimelineChange
37+
import uniffi.matrix_sdk_ui.LiveBackPaginationStatus
38+
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
39+
40+
class RustTimelineTest {
41+
@Test
42+
fun `ensure that the timeline emits new loading item when pagination does not bring new events`() = runTest {
43+
val inner = FakeRustTimeline()
44+
val systemClock = FakeSystemClock()
45+
val sut = createRustTimeline(
46+
inner = inner,
47+
systemClock = systemClock,
48+
)
49+
sut.timelineItems.test {
50+
// Give time for the listener to be set
51+
runCurrent()
52+
inner.emitDiff(
53+
listOf(
54+
FakeRustTimelineDiff(
55+
item = null,
56+
change = TimelineChange.RESET,
57+
)
58+
)
59+
)
60+
with(awaitItem()) {
61+
assertThat(size).isEqualTo(1)
62+
// Typing notification
63+
assertThat((get(0) as MatrixTimelineItem.Virtual).virtual).isEqualTo(VirtualTimelineItem.TypingNotification)
64+
}
65+
with(awaitItem()) {
66+
assertThat(size).isEqualTo(2)
67+
// The loading
68+
assertThat((get(0) as MatrixTimelineItem.Virtual).virtual).isEqualTo(
69+
VirtualTimelineItem.LoadingIndicator(
70+
direction = Timeline.PaginationDirection.BACKWARDS,
71+
timestamp = A_FAKE_TIMESTAMP,
72+
)
73+
)
74+
// Typing notification
75+
assertThat((get(1) as MatrixTimelineItem.Virtual).virtual).isEqualTo(VirtualTimelineItem.TypingNotification)
76+
}
77+
systemClock.epochMillisResult = A_FAKE_TIMESTAMP + 1
78+
// Start pagination
79+
sut.paginate(Timeline.PaginationDirection.BACKWARDS)
80+
// Simulate SDK starting pagination
81+
inner.emitPaginationStatus(LiveBackPaginationStatus.Paginating)
82+
// No new events received
83+
// Simulate SDK stopping pagination, more event to load
84+
inner.emitPaginationStatus(LiveBackPaginationStatus.Idle(hitStartOfTimeline = false))
85+
// expect an item to be emitted, with an updated timestamp
86+
with(awaitItem()) {
87+
assertThat(size).isEqualTo(2)
88+
// The loading
89+
assertThat((get(0) as MatrixTimelineItem.Virtual).virtual).isEqualTo(
90+
VirtualTimelineItem.LoadingIndicator(
91+
direction = Timeline.PaginationDirection.BACKWARDS,
92+
timestamp = A_FAKE_TIMESTAMP + 1,
93+
)
94+
)
95+
// Typing notification
96+
assertThat((get(1) as MatrixTimelineItem.Virtual).virtual).isEqualTo(VirtualTimelineItem.TypingNotification)
97+
}
98+
}
99+
}
100+
}
101+
102+
private fun TestScope.createRustTimeline(
103+
inner: InnerTimeline,
104+
mode: Timeline.Mode = Timeline.Mode.LIVE,
105+
systemClock: SystemClock = FakeSystemClock(),
106+
matrixRoom: MatrixRoom = FakeMatrixRoom().apply { givenRoomInfo(aRoomInfo()) },
107+
coroutineScope: CoroutineScope = backgroundScope,
108+
dispatcher: CoroutineDispatcher = testCoroutineDispatchers().io,
109+
roomContentForwarder: RoomContentForwarder = RoomContentForwarder(FakeRustRoomListService()),
110+
featureFlagsService: FeatureFlagService = FakeFeatureFlagService(),
111+
onNewSyncedEvent: () -> Unit = {},
112+
): RustTimeline {
113+
return RustTimeline(
114+
inner = inner,
115+
mode = mode,
116+
systemClock = systemClock,
117+
matrixRoom = matrixRoom,
118+
coroutineScope = coroutineScope,
119+
dispatcher = dispatcher,
120+
roomContentForwarder = roomContentForwarder,
121+
featureFlagsService = featureFlagsService,
122+
onNewSyncedEvent = onNewSyncedEvent,
123+
)
124+
}

libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ data class MediaInfo(
2525
val dateSent: String?,
2626
val dateSentFull: String?,
2727
val waveform: List<Float>?,
28+
val duration: String?,
2829
) : Parcelable
2930

3031
fun anImageMediaInfo(
@@ -45,13 +46,15 @@ fun anImageMediaInfo(
4546
dateSent = dateSent,
4647
dateSentFull = dateSentFull,
4748
waveform = null,
49+
duration = null,
4850
)
4951

5052
fun aVideoMediaInfo(
5153
caption: String? = null,
5254
senderName: String? = null,
5355
dateSent: String? = null,
5456
dateSentFull: String? = null,
57+
duration: String? = null,
5558
): MediaInfo = MediaInfo(
5659
filename = "a video file.mp4",
5760
caption = caption,
@@ -64,6 +67,7 @@ fun aVideoMediaInfo(
6467
dateSent = dateSent,
6568
dateSentFull = dateSentFull,
6669
waveform = null,
70+
duration = duration,
6771
)
6872

6973
fun aPdfMediaInfo(
@@ -84,6 +88,7 @@ fun aPdfMediaInfo(
8488
dateSent = dateSent,
8589
dateSentFull = dateSentFull,
8690
waveform = null,
91+
duration = null,
8792
)
8893

8994
fun anApkMediaInfo(
@@ -103,6 +108,7 @@ fun anApkMediaInfo(
103108
dateSent = dateSent,
104109
dateSentFull = dateSentFull,
105110
waveform = null,
111+
duration = null,
106112
)
107113

108114
fun anAudioMediaInfo(
@@ -112,6 +118,7 @@ fun anAudioMediaInfo(
112118
dateSent: String? = null,
113119
dateSentFull: String? = null,
114120
waveForm: List<Float>? = null,
121+
duration: String? = null,
115122
): MediaInfo = MediaInfo(
116123
filename = filename,
117124
caption = caption,
@@ -124,6 +131,7 @@ fun anAudioMediaInfo(
124131
dateSent = dateSent,
125132
dateSentFull = dateSentFull,
126133
waveform = waveForm,
134+
duration = duration,
127135
)
128136

129137
fun aVoiceMediaInfo(
@@ -133,6 +141,7 @@ fun aVoiceMediaInfo(
133141
dateSent: String? = null,
134142
dateSentFull: String? = null,
135143
waveForm: List<Float>? = null,
144+
duration: String? = null,
136145
): MediaInfo = MediaInfo(
137146
filename = filename,
138147
caption = caption,
@@ -145,4 +154,5 @@ fun aVoiceMediaInfo(
145154
dateSent = dateSent,
146155
dateSentFull = dateSentFull,
147156
waveform = waveForm,
157+
duration = duration,
148158
)

libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,17 @@ interface MediaViewerEntryPoint : FeatureEntryPoint {
3131
}
3232

3333
data class Params(
34+
val mode: MediaViewerMode,
3435
val eventId: EventId?,
3536
val mediaInfo: MediaInfo,
3637
val mediaSource: MediaSource,
3738
val thumbnailSource: MediaSource?,
3839
val canShowInfo: Boolean,
3940
) : NodeInputs
41+
42+
enum class MediaViewerMode {
43+
SingleMedia,
44+
TimelineImagesAndVideos,
45+
TimelineFilesAndAudios,
46+
}
4047
}

libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ class DefaultMediaViewerEntryPoint @Inject constructor() : MediaViewerEntryPoint
4242
val mimeType = MimeTypes.Images
4343
return params(
4444
MediaViewerEntryPoint.Params(
45+
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
4546
eventId = null,
4647
mediaInfo = MediaInfo(
4748
filename = filename,
@@ -55,6 +56,7 @@ class DefaultMediaViewerEntryPoint @Inject constructor() : MediaViewerEntryPoint
5556
dateSent = null,
5657
dateSentFull = null,
5758
waveform = null,
59+
duration = null,
5860
),
5961
mediaSource = MediaSource(url = avatarUrl),
6062
thumbnailSource = null,

0 commit comments

Comments
 (0)