Update Matrix Room API and allow media swipe on pinned event only.

This commit is contained in:
Benoit Marty 2025-02-17 16:45:05 +01:00
parent 37618600d7
commit c02436d3f0
30 changed files with 268 additions and 181 deletions

View file

@ -71,6 +71,7 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.alias.matches import io.element.android.libraries.matrix.api.room.alias.matches
import io.element.android.libraries.matrix.api.room.joinedRoomMembers import io.element.android.libraries.matrix.api.room.joinedRoomMembers
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.ui.messages.LocalRoomMemberProfilesCache import io.element.android.libraries.matrix.ui.messages.LocalRoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
@ -187,8 +188,11 @@ class MessagesFlowNode @AssistedInject constructor(
callbacks.forEach { it.onRoomDetailsClick() } callbacks.forEach { it.onRoomDetailsClick() }
} }
override fun onEventClick(event: TimelineItem.Event): Boolean { override fun onEventClick(isLive: Boolean, event: TimelineItem.Event): Boolean {
return processEventClick(event) return processEventClick(
timelineMode = if (isLive) Timeline.Mode.LIVE else Timeline.Mode.FOCUSED_ON_EVENT,
event = event,
)
} }
override fun onPreviewAttachments(attachments: ImmutableList<Attachment>) { override fun onPreviewAttachments(attachments: ImmutableList<Attachment>) {
@ -316,7 +320,10 @@ class MessagesFlowNode @AssistedInject constructor(
NavTarget.PinnedMessagesList -> { NavTarget.PinnedMessagesList -> {
val callback = object : PinnedMessagesListNode.Callback { val callback = object : PinnedMessagesListNode.Callback {
override fun onEventClick(event: TimelineItem.Event) { override fun onEventClick(event: TimelineItem.Event) {
processEventClick(event) processEventClick(
timelineMode = Timeline.Mode.PINNED_EVENTS,
event = event,
)
} }
override fun onUserDataClick(userId: UserId) { override fun onUserDataClick(userId: UserId) {
@ -358,11 +365,14 @@ class MessagesFlowNode @AssistedInject constructor(
callbacks.forEach { it.onPermalinkClick(permalinkData, pushToBackstack = false) } callbacks.forEach { it.onPermalinkClick(permalinkData, pushToBackstack = false) }
} }
private fun processEventClick(event: TimelineItem.Event): Boolean { private fun processEventClick(
timelineMode: Timeline.Mode,
event: TimelineItem.Event,
): Boolean {
val navTarget = when (event.content) { val navTarget = when (event.content) {
is TimelineItemImageContent -> { is TimelineItemImageContent -> {
buildMediaViewerNavTarget( buildMediaViewerNavTarget(
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos, mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(timelineMode),
event = event, event = event,
content = event.content, content = event.content,
mediaSource = event.content.mediaSource, mediaSource = event.content.mediaSource,
@ -374,7 +384,7 @@ class MessagesFlowNode @AssistedInject constructor(
if encrypted on certain bridges */ if encrypted on certain bridges */
event.content.preferredMediaSource?.let { preferredMediaSource -> event.content.preferredMediaSource?.let { preferredMediaSource ->
buildMediaViewerNavTarget( buildMediaViewerNavTarget(
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos, mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(timelineMode),
event = event, event = event,
content = event.content, content = event.content,
mediaSource = preferredMediaSource, mediaSource = preferredMediaSource,
@ -384,7 +394,7 @@ class MessagesFlowNode @AssistedInject constructor(
} }
is TimelineItemVideoContent -> { is TimelineItemVideoContent -> {
buildMediaViewerNavTarget( buildMediaViewerNavTarget(
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos, mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(timelineMode),
event = event, event = event,
content = event.content, content = event.content,
mediaSource = event.content.mediaSource, mediaSource = event.content.mediaSource,
@ -393,7 +403,7 @@ class MessagesFlowNode @AssistedInject constructor(
} }
is TimelineItemFileContent -> { is TimelineItemFileContent -> {
buildMediaViewerNavTarget( buildMediaViewerNavTarget(
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios, mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode),
event = event, event = event,
content = event.content, content = event.content,
mediaSource = event.content.mediaSource, mediaSource = event.content.mediaSource,
@ -402,7 +412,7 @@ class MessagesFlowNode @AssistedInject constructor(
} }
is TimelineItemAudioContent -> { is TimelineItemAudioContent -> {
buildMediaViewerNavTarget( buildMediaViewerNavTarget(
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios, mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode),
event = event, event = event,
content = event.content, content = event.content,
mediaSource = event.content.mediaSource, mediaSource = event.content.mediaSource,

View file

@ -89,7 +89,7 @@ class MessagesNode @AssistedInject constructor(
interface Callback : Plugin { interface Callback : Plugin {
fun onRoomDetailsClick() fun onRoomDetailsClick()
fun onEventClick(event: TimelineItem.Event): Boolean fun onEventClick(isLive: Boolean, event: TimelineItem.Event): Boolean
fun onPreviewAttachments(attachments: ImmutableList<Attachment>) fun onPreviewAttachments(attachments: ImmutableList<Attachment>)
fun onUserDataClick(userId: UserId) fun onUserDataClick(userId: UserId)
fun onPermalinkClick(data: PermalinkData) fun onPermalinkClick(data: PermalinkData)
@ -120,12 +120,12 @@ class MessagesNode @AssistedInject constructor(
callbacks.forEach { it.onRoomDetailsClick() } callbacks.forEach { it.onRoomDetailsClick() }
} }
private fun onEventClick(event: TimelineItem.Event): Boolean { private fun onEventClick(isLive: Boolean, event: TimelineItem.Event): Boolean {
// Note: cannot use `callbacks.all { it.onEventClick(event) }` because: // Note: cannot use `callbacks.all { it.onEventClick(event) }` because:
// - if callbacks is empty, it will return true and we want to return false. // - if callbacks is empty, it will return true and we want to return false.
// - if a callback returns false, the other callback will not be invoked. // - if a callback returns false, the other callback will not be invoked.
return callbacks.takeIf { it.isNotEmpty() } return callbacks.takeIf { it.isNotEmpty() }
?.map { it.onEventClick(event) } ?.map { it.onEventClick(isLive, event) }
?.all { it } ?.all { it }
.orFalse() .orFalse()
} }

View file

@ -109,7 +109,7 @@ fun MessagesView(
state: MessagesState, state: MessagesState,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onRoomDetailsClick: () -> Unit, onRoomDetailsClick: () -> Unit,
onEventContentClick: (event: TimelineItem.Event) -> Boolean, onEventContentClick: (isLive: Boolean, event: TimelineItem.Event) -> Boolean,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
onLinkClick: (String, Boolean) -> Unit, onLinkClick: (String, Boolean) -> Unit,
onSendLocationClick: () -> Unit, onSendLocationClick: () -> Unit,
@ -140,7 +140,7 @@ fun MessagesView(
fun onContentClick(event: TimelineItem.Event) { fun onContentClick(event: TimelineItem.Event) {
Timber.v("onMessageClick= ${event.id}") Timber.v("onMessageClick= ${event.id}")
val hideKeyboard = onEventContentClick(event) val hideKeyboard = onEventContentClick(state.timelineState.isLive, event)
if (hideKeyboard) { if (hideKeyboard) {
localView.hideKeyboard() localView.hideKeyboard()
} }
@ -535,7 +535,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
state = state, state = state,
onBackClick = {}, onBackClick = {},
onRoomDetailsClick = {}, onRoomDetailsClick = {},
onEventContentClick = { false }, onEventContentClick = { _, _ -> false },
onUserDataClick = {}, onUserDataClick = {},
onLinkClick = { _, _ -> }, onLinkClick = { _, _ -> },
onSendLocationClick = {}, onSendLocationClick = {},

View file

@ -33,7 +33,7 @@ internal fun MessagesViewWithIdentityChangePreview(
), ),
onBackClick = {}, onBackClick = {},
onRoomDetailsClick = {}, onRoomDetailsClick = {},
onEventContentClick = { false }, onEventContentClick = { _, _ -> false },
onUserDataClick = {}, onUserDataClick = {},
onLinkClick = { _, _ -> }, onLinkClick = { _, _ -> },
onSendLocationClick = {}, onSendLocationClick = {},

View file

@ -104,7 +104,7 @@ class PinnedEventsTimelineProvider @Inject constructor(
is AsyncData.Uninitialized, is AsyncData.Failure -> { is AsyncData.Uninitialized, is AsyncData.Failure -> {
timelineStateFlow.emit(AsyncData.Loading()) timelineStateFlow.emit(AsyncData.Loading())
withContext(dispatchers.io) { withContext(dispatchers.io) {
room.pinnedEventsTimeline() room.createTimeline(onlyPinnedEvents = true)
} }
.fold( .fold(
{ timelineStateFlow.emit(AsyncData.Success(it)) }, { timelineStateFlow.emit(AsyncData.Success(it)) },

View file

@ -64,7 +64,7 @@ class TimelineController @Inject constructor(
} }
suspend fun focusOnEvent(eventId: EventId): Result<Unit> { suspend fun focusOnEvent(eventId: EventId): Result<Unit> {
return room.timelineFocusedOnEvent(eventId) return room.createTimeline(focusedOnEventId = eventId)
.onFailure { .onFailure {
if (it is CancellationException) { if (it is CancellationException) {
throw it throw it

View file

@ -54,11 +54,11 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureCalledOnceWithParam import io.element.android.tests.testutils.EnsureCalledOnceWithTwoParamsAndResult
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EnsureNeverCalledWithParamAndResult
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParamsAndResult
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
@ -129,8 +129,9 @@ class MessagesViewTest {
eventSink = eventsRecorder eventSink = eventsRecorder
) )
val timelineItem = state.timelineState.timelineItems.first() val timelineItem = state.timelineState.timelineItems.first()
val callback = EnsureCalledOnceWithParam( val callback = EnsureCalledOnceWithTwoParamsAndResult(
expectedParam = timelineItem, expectedParam1 = true,
expectedParam2 = timelineItem,
result = true, result = true,
) )
rule.setMessagesView( rule.setMessagesView(
@ -513,7 +514,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
state: MessagesState, state: MessagesState,
onBackClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(),
onRoomDetailsClick: () -> Unit = EnsureNeverCalled(), onRoomDetailsClick: () -> Unit = EnsureNeverCalled(),
onEventClick: (event: TimelineItem.Event) -> Boolean = EnsureNeverCalledWithParamAndResult(), onEventClick: (isLive: Boolean, event: TimelineItem.Event) -> Boolean = EnsureNeverCalledWithTwoParamsAndResult(),
onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(), onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
onLinkClick: (String, Boolean) -> Unit = EnsureNeverCalledWithTwoParams(), onLinkClick: (String, Boolean) -> Unit = EnsureNeverCalledWithTwoParams(),
onSendLocationClick: () -> Unit = EnsureNeverCalled(), onSendLocationClick: () -> Unit = EnsureNeverCalled(),

View file

@ -55,7 +55,7 @@ class PinnedMessagesBannerPresenterTest {
@Test @Test
fun `present - loading state`() = runTest { fun `present - loading state`() = runTest {
val room = FakeMatrixRoom( val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(FakeTimeline()) } createTimelineResult = { _, _, _ -> Result.success(FakeTimeline()) }
).apply { ).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
} }
@ -86,7 +86,7 @@ class PinnedMessagesBannerPresenterTest {
) )
) )
val room = FakeMatrixRoom( val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) } createTimelineResult = { _, _, _ -> Result.success(pinnedEventsTimeline) }
).apply { ).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID, AN_EVENT_ID_2))) givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID, AN_EVENT_ID_2)))
} }
@ -125,7 +125,7 @@ class PinnedMessagesBannerPresenterTest {
) )
) )
val room = FakeMatrixRoom( val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) } createTimelineResult = { _, _, _ -> Result.success(pinnedEventsTimeline) }
).apply { ).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID, AN_EVENT_ID_2))) givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID, AN_EVENT_ID_2)))
} }
@ -160,7 +160,7 @@ class PinnedMessagesBannerPresenterTest {
@Test @Test
fun `present - timeline failed`() = runTest { fun `present - timeline failed`() = runTest {
val room = FakeMatrixRoom( val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.failure(Exception()) } createTimelineResult = { _, _, _ -> Result.failure(Exception()) }
).apply { ).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
} }

View file

@ -83,7 +83,7 @@ class PinnedMessagesListPresenterTest {
@Test @Test
fun `present - timeline failure state`() = runTest { fun `present - timeline failure state`() = runTest {
val room = FakeMatrixRoom( val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.failure(RuntimeException()) }, createTimelineResult = { _, _, _ -> Result.failure(RuntimeException()) },
canRedactOwnResult = { Result.success(true) }, canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) }, canUserPinUnpinResult = { Result.success(true) },
@ -102,7 +102,7 @@ class PinnedMessagesListPresenterTest {
@Test @Test
fun `present - empty state`() = runTest { fun `present - empty state`() = runTest {
val room = FakeMatrixRoom( val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(FakeTimeline()) }, createTimelineResult = { _, _, _ -> Result.success(FakeTimeline()) },
canRedactOwnResult = { Result.success(true) }, canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) }, canUserPinUnpinResult = { Result.success(true) },
@ -122,7 +122,7 @@ class PinnedMessagesListPresenterTest {
fun `present - filled state`() = runTest { fun `present - filled state`() = runTest {
val pinnedEventsTimeline = createPinnedMessagesTimeline() val pinnedEventsTimeline = createPinnedMessagesTimeline()
val room = FakeMatrixRoom( val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) }, createTimelineResult = { _, _, _ -> Result.success(pinnedEventsTimeline) },
canRedactOwnResult = { Result.success(true) }, canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) }, canUserPinUnpinResult = { Result.success(true) },
@ -149,7 +149,7 @@ class PinnedMessagesListPresenterTest {
val pinnedEventsTimeline = createPinnedMessagesTimeline() val pinnedEventsTimeline = createPinnedMessagesTimeline()
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val room = FakeMatrixRoom( val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) }, createTimelineResult = { _, _, _ -> Result.success(pinnedEventsTimeline) },
canRedactOwnResult = { Result.success(true) }, canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) }, canUserPinUnpinResult = { Result.success(true) },
@ -195,7 +195,7 @@ class PinnedMessagesListPresenterTest {
} }
val pinnedEventsTimeline = createPinnedMessagesTimeline() val pinnedEventsTimeline = createPinnedMessagesTimeline()
val room = FakeMatrixRoom( val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) }, createTimelineResult = { _, _, _ -> Result.success(pinnedEventsTimeline) },
canRedactOwnResult = { Result.success(true) }, canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) }, canUserPinUnpinResult = { Result.success(true) },
@ -224,7 +224,7 @@ class PinnedMessagesListPresenterTest {
} }
val pinnedEventsTimeline = createPinnedMessagesTimeline() val pinnedEventsTimeline = createPinnedMessagesTimeline()
val room = FakeMatrixRoom( val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) }, createTimelineResult = { _, _, _ -> Result.success(pinnedEventsTimeline) },
canRedactOwnResult = { Result.success(true) }, canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) }, canUserPinUnpinResult = { Result.success(true) },
@ -253,7 +253,7 @@ class PinnedMessagesListPresenterTest {
} }
val pinnedEventsTimeline = createPinnedMessagesTimeline() val pinnedEventsTimeline = createPinnedMessagesTimeline()
val room = FakeMatrixRoom( val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) }, createTimelineResult = { _, _, _ -> Result.success(pinnedEventsTimeline) },
canRedactOwnResult = { Result.success(true) }, canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) }, canUserPinUnpinResult = { Result.success(true) },

View file

@ -31,7 +31,7 @@ class TimelineControllerTest {
val detachedTimeline = FakeTimeline(name = "detached") val detachedTimeline = FakeTimeline(name = "detached")
val matrixRoom = FakeMatrixRoom( val matrixRoom = FakeMatrixRoom(
liveTimeline = liveTimeline, liveTimeline = liveTimeline,
timelineFocusedOnEventResult = { Result.success(detachedTimeline) } createTimelineResult = { _, _, _ -> Result.success(detachedTimeline) }
) )
val sut = TimelineController(matrixRoom) val sut = TimelineController(matrixRoom)
@ -63,7 +63,7 @@ class TimelineControllerTest {
var callNumber = 0 var callNumber = 0
val matrixRoom = FakeMatrixRoom( val matrixRoom = FakeMatrixRoom(
liveTimeline = liveTimeline, liveTimeline = liveTimeline,
timelineFocusedOnEventResult = { createTimelineResult = { _, _, _ ->
callNumber++ callNumber++
when (callNumber) { when (callNumber) {
1 -> Result.success(detachedTimeline1) 1 -> Result.success(detachedTimeline1)
@ -117,7 +117,7 @@ class TimelineControllerTest {
val detachedTimeline = FakeTimeline(name = "detached") val detachedTimeline = FakeTimeline(name = "detached")
val matrixRoom = FakeMatrixRoom( val matrixRoom = FakeMatrixRoom(
liveTimeline = liveTimeline, liveTimeline = liveTimeline,
timelineFocusedOnEventResult = { Result.success(detachedTimeline) } createTimelineResult = { _, _, _ -> Result.success(detachedTimeline) }
) )
val sut = TimelineController(matrixRoom) val sut = TimelineController(matrixRoom)
sut.activeTimelineFlow().test { sut.activeTimelineFlow().test {
@ -167,7 +167,7 @@ class TimelineControllerTest {
} }
val matrixRoom = FakeMatrixRoom( val matrixRoom = FakeMatrixRoom(
liveTimeline = liveTimeline, liveTimeline = liveTimeline,
timelineFocusedOnEventResult = { Result.success(detachedTimeline) } createTimelineResult = { _, _, _ -> Result.success(detachedTimeline) }
) )
val sut = TimelineController(matrixRoom) val sut = TimelineController(matrixRoom)
sut.activeTimelineFlow().test { sut.activeTimelineFlow().test {
@ -192,7 +192,7 @@ class TimelineControllerTest {
val detachedTimeline = FakeTimeline(name = "detached") val detachedTimeline = FakeTimeline(name = "detached")
val matrixRoom = FakeMatrixRoom( val matrixRoom = FakeMatrixRoom(
liveTimeline = liveTimeline, liveTimeline = liveTimeline,
timelineFocusedOnEventResult = { Result.success(detachedTimeline) } createTimelineResult = { _, _, _ -> Result.success(detachedTimeline) }
) )
val sut = TimelineController(matrixRoom) val sut = TimelineController(matrixRoom)

View file

@ -483,7 +483,7 @@ import kotlin.time.Duration.Companion.seconds
) )
val room = FakeMatrixRoom( val room = FakeMatrixRoom(
liveTimeline = liveTimeline, liveTimeline = liveTimeline,
timelineFocusedOnEventResult = { Result.success(detachedTimeline) }, createTimelineResult = { _, _, _ -> Result.success(detachedTimeline) },
canUserSendMessageResult = { _, _ -> Result.success(true) }, canUserSendMessageResult = { _, _ -> Result.success(true) },
) )
val presenter = createTimelinePresenter( val presenter = createTimelinePresenter(
@ -561,7 +561,7 @@ import kotlin.time.Duration.Companion.seconds
liveTimeline = FakeTimeline( liveTimeline = FakeTimeline(
timelineItems = flowOf(emptyList()), timelineItems = flowOf(emptyList()),
), ),
timelineFocusedOnEventResult = { Result.failure(Throwable("An error")) }, createTimelineResult = { _, _, _ -> Result.failure(Throwable("An error")) },
canUserSendMessageResult = { _, _ -> Result.success(true) }, canUserSendMessageResult = { _, _ -> Result.success(true) },
) )
) )

View file

@ -109,21 +109,17 @@ interface MatrixRoom : Closeable {
val liveTimeline: Timeline val liveTimeline: Timeline
/** /**
* Create a new timeline, focused on the provided Event. * Create a new timeline.
* Should not be used directly, see `TimelineController` to manage the various timelines. * @param focusedOnEventId The event to focus on, if any. Note: if not null, and for regular timeline,
* this method should not be used directly, see `TimelineController` to manage the various timelines.
* @param onlyPinnedEvents True to get the timeline for pinned events only.
* @param onlyMedia True to get the timeline for media events only.
*/ */
suspend fun timelineFocusedOnEvent(eventId: EventId): Result<Timeline> suspend fun createTimeline(
focusedOnEventId: EventId? = null,
/** onlyPinnedEvents: Boolean = false,
* Create a new timeline for the pinned events of the room. onlyMedia: Boolean = false,
*/ ): Result<Timeline>
suspend fun pinnedEventsTimeline(): Result<Timeline>
/**
* Create a new timeline for the media events of the room.
* @param eventId The event to focus on, if any.
*/
suspend fun mediaTimeline(eventId: EventId?): Result<Timeline>
fun destroy() fun destroy()

View file

@ -214,80 +214,72 @@ class RustMatrixRoom(
override suspend fun subscribeToSync() = roomSyncSubscriber.subscribe(roomId) override suspend fun subscribeToSync() = roomSyncSubscriber.subscribe(roomId)
override suspend fun timelineFocusedOnEvent(eventId: EventId): Result<Timeline> = withContext(roomDispatcher) { override suspend fun createTimeline(
runCatching { focusedOnEventId: EventId?,
innerRoom.timelineWithConfiguration( onlyPinnedEvents: Boolean,
configuration = TimelineConfiguration( onlyMedia: Boolean,
focus = TimelineFocus.Event(
eventId = eventId.value,
numContextEvents = 50u,
),
allowedMessageTypes = AllowedMessageTypes.All,
internalIdPrefix = "focus_$eventId",
dateDividerMode = DateDividerMode.DAILY,
)
).let { inner ->
createTimeline(inner, mode = Timeline.Mode.FOCUSED_ON_EVENT)
}
}.mapFailure {
it.toFocusEventException()
}.onFailure {
if (it is CancellationException) {
throw it
}
}
}
override suspend fun pinnedEventsTimeline(): Result<Timeline> = withContext(roomDispatcher) {
runCatching {
innerRoom.timelineWithConfiguration(
configuration = TimelineConfiguration(
focus = TimelineFocus.PinnedEvents(
maxEventsToLoad = 100u,
maxConcurrentRequests = 10u,
),
allowedMessageTypes = AllowedMessageTypes.All,
internalIdPrefix = "pinned_events",
dateDividerMode = DateDividerMode.DAILY,
)
).let { inner ->
createTimeline(inner, mode = Timeline.Mode.PINNED_EVENTS)
}
}.onFailure {
if (it is CancellationException) {
throw it
}
}
}
override suspend fun mediaTimeline(
eventId: EventId?,
): Result<Timeline> = withContext(roomDispatcher) { ): Result<Timeline> = withContext(roomDispatcher) {
val focus = if (eventId != null) { val focus = if (onlyPinnedEvents) {
TimelineFocus.PinnedEvents(
maxEventsToLoad = 100u,
maxConcurrentRequests = 10u,
)
} else if (focusedOnEventId != null) {
TimelineFocus.Event( TimelineFocus.Event(
eventId = eventId.value, eventId = focusedOnEventId.value,
numContextEvents = 50u, numContextEvents = 50u,
) )
} else { } else {
TimelineFocus.Live TimelineFocus.Live
} }
val allowedMessageTypes = if (onlyMedia) {
AllowedMessageTypes.Only(
types = listOf(
RoomMessageEventMessageType.FILE,
RoomMessageEventMessageType.IMAGE,
RoomMessageEventMessageType.VIDEO,
RoomMessageEventMessageType.AUDIO,
)
)
} else {
AllowedMessageTypes.All
}
val internalIdPrefix = if (onlyPinnedEvents) {
"pinned_events"
} else if (focusedOnEventId != null) {
"focus_$focusedOnEventId"
} else if (onlyMedia) {
"MediaGallery_"
} else {
"live"
}
val dateDividerMode = if (onlyMedia) {
DateDividerMode.MONTHLY
} else {
DateDividerMode.DAILY
}
val mode = when {
onlyPinnedEvents -> Timeline.Mode.PINNED_EVENTS
focusedOnEventId != null -> Timeline.Mode.FOCUSED_ON_EVENT
onlyMedia -> Timeline.Mode.MEDIA
else -> Timeline.Mode.LIVE
}
runCatching { runCatching {
innerRoom.timelineWithConfiguration( innerRoom.timelineWithConfiguration(
configuration = TimelineConfiguration( configuration = TimelineConfiguration(
focus = focus, focus = focus,
allowedMessageTypes = AllowedMessageTypes.Only( allowedMessageTypes = allowedMessageTypes,
types = listOf( internalIdPrefix = internalIdPrefix,
RoomMessageEventMessageType.FILE, dateDividerMode = dateDividerMode,
RoomMessageEventMessageType.IMAGE,
RoomMessageEventMessageType.VIDEO,
RoomMessageEventMessageType.AUDIO,
)
),
internalIdPrefix = "MediaGallery_",
dateDividerMode = DateDividerMode.MONTHLY,
) )
).let { inner -> ).let { inner ->
createTimeline(inner, mode = if (eventId != null) Timeline.Mode.FOCUSED_ON_EVENT else Timeline.Mode.MEDIA) createTimeline(inner, mode = mode)
}
}.mapFailure {
if (focusedOnEventId != null) {
it.toFocusEventException()
} else {
it
} }
}.onFailure { }.onFailure {
if (it is CancellationException) { if (it is CancellationException) {

View file

@ -138,9 +138,7 @@ class FakeMatrixRoom(
private val leaveRoomLambda: () -> Result<Unit> = { lambdaError() }, private val leaveRoomLambda: () -> Result<Unit> = { lambdaError() },
private val updateMembersResult: () -> Unit = { lambdaError() }, private val updateMembersResult: () -> Unit = { lambdaError() },
private val getMembersResult: (Int) -> Result<List<RoomMember>> = { lambdaError() }, private val getMembersResult: (Int) -> Result<List<RoomMember>> = { lambdaError() },
private val timelineFocusedOnEventResult: (EventId) -> Result<Timeline> = { lambdaError() }, private val createTimelineResult: (EventId?, Boolean, Boolean) -> Result<Timeline> = { _, _, _ -> lambdaError() },
private val pinnedEventsTimelineResult: () -> Result<Timeline> = { lambdaError() },
private val mediaTimelineResult: (EventId?) -> Result<Timeline> = { lambdaError() },
private val setSendQueueEnabledLambda: (Boolean) -> Unit = { _: Boolean -> }, private val setSendQueueEnabledLambda: (Boolean) -> Unit = { _: Boolean -> },
private val saveComposerDraftLambda: (ComposerDraft) -> Result<Unit> = { _: ComposerDraft -> Result.success(Unit) }, private val saveComposerDraftLambda: (ComposerDraft) -> Result<Unit> = { _: ComposerDraft -> Result.success(Unit) },
private val loadComposerDraftLambda: () -> Result<ComposerDraft?> = { Result.success<ComposerDraft?>(null) }, private val loadComposerDraftLambda: () -> Result<ComposerDraft?> = { Result.success<ComposerDraft?>(null) },
@ -220,16 +218,12 @@ class FakeMatrixRoom(
_syncUpdateFlow.tryEmit(_syncUpdateFlow.value + 1) _syncUpdateFlow.tryEmit(_syncUpdateFlow.value + 1)
} }
override suspend fun timelineFocusedOnEvent(eventId: EventId): Result<Timeline> = simulateLongTask { override suspend fun createTimeline(
timelineFocusedOnEventResult(eventId) focusedOnEventId: EventId?,
} onlyPinnedEvents: Boolean,
onlyMedia: Boolean,
override suspend fun pinnedEventsTimeline(): Result<Timeline> = simulateLongTask { ): Result<Timeline> = simulateLongTask {
pinnedEventsTimelineResult() createTimelineResult(focusedOnEventId, onlyPinnedEvents, onlyMedia)
}
override suspend fun mediaTimeline(eventId: EventId?): Result<Timeline> = simulateLongTask {
mediaTimelineResult(eventId)
} }
override suspend fun subscribeToSync() { override suspend fun subscribeToSync() {

View file

@ -7,6 +7,7 @@
package io.element.android.libraries.mediaviewer.api package io.element.android.libraries.mediaviewer.api
import android.os.Parcelable
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
@ -14,6 +15,8 @@ import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.matrix.api.core.EventId 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.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.Timeline
import kotlinx.parcelize.Parcelize
interface MediaViewerEntryPoint : FeatureEntryPoint { interface MediaViewerEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
@ -39,9 +42,14 @@ interface MediaViewerEntryPoint : FeatureEntryPoint {
val canShowInfo: Boolean, val canShowInfo: Boolean,
) : NodeInputs ) : NodeInputs
enum class MediaViewerMode { sealed interface MediaViewerMode : Parcelable {
SingleMedia, @Parcelize
TimelineImagesAndVideos, data object SingleMedia : MediaViewerMode
TimelineFilesAndAudios,
@Parcelize
data class TimelineImagesAndVideos(val timelineMode: Timeline.Mode) : MediaViewerMode
@Parcelize
data class TimelineFilesAndAudios(val timelineMode: Timeline.Mode) : MediaViewerMode
} }
} }

View file

@ -18,6 +18,7 @@ interface FocusedTimelineMediaGalleryDataSourceFactory {
fun createFor( fun createFor(
eventId: EventId, eventId: EventId,
mediaItem: MediaItem.Event, mediaItem: MediaItem.Event,
onlyPinnedEvents: Boolean,
): MediaGalleryDataSource ): MediaGalleryDataSource
} }
@ -30,6 +31,7 @@ class DefaultFocusedTimelineMediaGalleryDataSourceFactory @Inject constructor(
override fun createFor( override fun createFor(
eventId: EventId, eventId: EventId,
mediaItem: MediaItem.Event, mediaItem: MediaItem.Event,
onlyPinnedEvents: Boolean,
): MediaGalleryDataSource { ): MediaGalleryDataSource {
return TimelineMediaGalleryDataSource( return TimelineMediaGalleryDataSource(
room = room, room = room,
@ -37,6 +39,7 @@ class DefaultFocusedTimelineMediaGalleryDataSourceFactory @Inject constructor(
room = room, room = room,
eventId = eventId, eventId = eventId,
initialMediaItem = mediaItem, initialMediaItem = mediaItem,
onlyPinnedEvents = onlyPinnedEvents,
), ),
timelineMediaItemsFactory = timelineMediaItemsFactory, timelineMediaItemsFactory = timelineMediaItemsFactory,
mediaItemsPostProcessor = mediaItemsPostProcessor, mediaItemsPostProcessor = mediaItemsPostProcessor,

View file

@ -44,7 +44,7 @@ class LiveMediaTimeline @Inject constructor(
override suspend fun getTimeline(): Result<Timeline> = mutex.withLock { override suspend fun getTimeline(): Result<Timeline> = mutex.withLock {
val currentTimeline = timeline val currentTimeline = timeline
if (currentTimeline == null) { if (currentTimeline == null) {
room.mediaTimeline(null) room.createTimeline(onlyMedia = true)
.onSuccess { timeline = it } .onSuccess { timeline = it }
} else { } else {
Result.success(currentTimeline) Result.success(currentTimeline)
@ -58,14 +58,20 @@ class LiveMediaTimeline @Inject constructor(
/** /**
* A class that will provide a media timeline that is focused on a particular event. * A class that will provide a media timeline that is focused on a particular event.
* Optionally, the timeline will only contain the pinned events.
*/ */
class FocusedMediaTimeline( class FocusedMediaTimeline(
private val room: MatrixRoom, private val room: MatrixRoom,
private val eventId: EventId, private val eventId: EventId,
private val onlyPinnedEvents: Boolean,
initialMediaItem: MediaItem.Event, initialMediaItem: MediaItem.Event,
) : MediaTimeline { ) : MediaTimeline {
override suspend fun getTimeline(): Result<Timeline> { override suspend fun getTimeline(): Result<Timeline> {
return room.mediaTimeline(eventId) return room.createTimeline(
focusedOnEventId = eventId,
onlyPinnedEvents = onlyPinnedEvents,
onlyMedia = true,
)
} }
override val cache = persistentListOf( override val cache = persistentListOf(

View file

@ -27,6 +27,7 @@ import io.element.android.libraries.architecture.overlay.operation.show
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId 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.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint
import io.element.android.libraries.mediaviewer.api.MediaInfo import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
@ -96,9 +97,9 @@ class MediaGalleryRootNode @AssistedInject constructor(
val mode = when (item) { val mode = when (item) {
is MediaItem.Audio, is MediaItem.Audio,
is MediaItem.Voice, is MediaItem.Voice,
is MediaItem.File -> MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios is MediaItem.File -> MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(Timeline.Mode.MEDIA)
is MediaItem.Image, is MediaItem.Image,
is MediaItem.Video -> MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos is MediaItem.Video -> MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.MEDIA)
} }
overlay.show( overlay.show(
NavTarget.MediaViewer( NavTarget.MediaViewer(

View file

@ -52,8 +52,8 @@ class MediaViewerDataSource(
private val galleryMode = when (mode) { private val galleryMode = when (mode) {
MediaViewerMode.SingleMedia, MediaViewerMode.SingleMedia,
MediaViewerMode.TimelineImagesAndVideos -> MediaGalleryMode.Images is MediaViewerMode.TimelineImagesAndVideos -> MediaGalleryMode.Images
MediaViewerMode.TimelineFilesAndAudios -> MediaGalleryMode.Files is MediaViewerMode.TimelineFilesAndAudios -> MediaGalleryMode.Files
} }
// Map of sourceUrl to local media state // Map of sourceUrl to local media state

View file

@ -22,6 +22,7 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId 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.MatrixMediaLoader
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.mediaviewer.impl.datasource.FocusedTimelineMediaGalleryDataSourceFactory import io.element.android.libraries.mediaviewer.impl.datasource.FocusedTimelineMediaGalleryDataSourceFactory
@ -69,16 +70,40 @@ class MediaViewerNode @AssistedInject constructor(
// Should not happen // Should not happen
timelineMediaGalleryDataSource timelineMediaGalleryDataSource
} else { } else {
// Does timelineMediaGalleryDataSource knows the eventId? // Can we use a specific timeline?
val lastData = timelineMediaGalleryDataSource.getLastData().dataOrNull() val timelineMode = when (val mode = inputs.mode) {
val isEventKnown = lastData?.hasEvent(eventId) == true is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> mode.timelineMode
if (isEventKnown) { is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> mode.timelineMode
timelineMediaGalleryDataSource else -> null
} else { }
focusedTimelineMediaGalleryDataSourceFactory.createFor( when (timelineMode) {
eventId = eventId, null -> timelineMediaGalleryDataSource
mediaItem = inputs.toMediaItem(), Timeline.Mode.LIVE -> {
) // Even if the timelineMediaGalleryDataSource does not know the eventId, the SDK will create the timeline faster
timelineMediaGalleryDataSource
}
Timeline.Mode.FOCUSED_ON_EVENT -> {
// Does timelineMediaGalleryDataSource knows the eventId?
val lastData = timelineMediaGalleryDataSource.getLastData().dataOrNull()
val isEventKnown = lastData?.hasEvent(eventId) == true
if (isEventKnown) {
timelineMediaGalleryDataSource
} else {
focusedTimelineMediaGalleryDataSourceFactory.createFor(
eventId = eventId,
mediaItem = inputs.toMediaItem(),
onlyPinnedEvents = false,
)
}
}
Timeline.Mode.PINNED_EVENTS -> {
focusedTimelineMediaGalleryDataSourceFactory.createFor(
eventId = eventId,
mediaItem = inputs.toMediaItem(),
onlyPinnedEvents = true,
)
}
Timeline.Mode.MEDIA -> timelineMediaGalleryDataSource
} }
} }
} }

View file

@ -204,8 +204,8 @@ class MediaViewerPresenter @AssistedInject constructor(
private fun showNoMoreItemsSnackbar() { private fun showNoMoreItemsSnackbar() {
val messageResId = when (inputs.mode) { val messageResId = when (inputs.mode) {
MediaViewerEntryPoint.MediaViewerMode.SingleMedia, MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> R.string.screen_media_details_no_more_media_to_show is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> R.string.screen_media_details_no_more_media_to_show
MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> R.string.screen_media_details_no_more_files_to_show is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> R.string.screen_media_details_no_more_files_to_show
} }
val message = SnackbarMessage(messageResId) val message = SnackbarMessage(messageResId)
snackbarDispatcher.post(message) snackbarDispatcher.post(message)

View file

@ -25,6 +25,7 @@ class DefaultFocusedTimelineMediaGalleryDataSourceFactoryTest {
val result = sut.createFor( val result = sut.createFor(
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
mediaItem = aMediaItemImage(), mediaItem = aMediaItemImage(),
onlyPinnedEvents = false,
) )
assertThat(result).isInstanceOf(TimelineMediaGalleryDataSource::class.java) assertThat(result).isInstanceOf(TimelineMediaGalleryDataSource::class.java)
} }

View file

@ -75,11 +75,11 @@ class FocusedMediaTimelineTest {
@Test @Test
fun `getTimeline returns the timeline provided by the room`() = runTest { fun `getTimeline returns the timeline provided by the room`() = runTest {
val mediaTimelineResult = lambdaRecorder<EventId?, Result<Timeline>> { val createTimelineResult = lambdaRecorder<EventId?, Boolean, Boolean, Result<Timeline>> { _, _, _ ->
Result.success(FakeTimeline()) Result.success(FakeTimeline())
} }
val room = FakeMatrixRoom( val room = FakeMatrixRoom(
mediaTimelineResult = mediaTimelineResult, createTimelineResult = createTimelineResult,
) )
val sut = createFocusedMediaTimeline( val sut = createFocusedMediaTimeline(
room = room, room = room,
@ -87,16 +87,38 @@ class FocusedMediaTimelineTest {
) )
val timeline = sut.getTimeline() val timeline = sut.getTimeline()
assertThat(timeline.isSuccess).isTrue() assertThat(timeline.isSuccess).isTrue()
mediaTimelineResult.assertions().isCalledOnce().with(value(AN_EVENT_ID)) createTimelineResult.assertions().isCalledOnce()
.with(value(AN_EVENT_ID), value(false), value(true))
}
@Test
fun `getTimeline returns the timeline provided by the room for pinned Events`() = runTest {
val createTimelineResult = lambdaRecorder<EventId?, Boolean, Boolean, Result<Timeline>> { _, _, _ ->
Result.success(FakeTimeline())
}
val room = FakeMatrixRoom(
createTimelineResult = createTimelineResult,
)
val sut = createFocusedMediaTimeline(
room = room,
eventId = AN_EVENT_ID,
onlyPinnedEvent = true,
)
val timeline = sut.getTimeline()
assertThat(timeline.isSuccess).isTrue()
createTimelineResult.assertions().isCalledOnce()
.with(value(AN_EVENT_ID), value(true), value(true))
} }
private fun createFocusedMediaTimeline( private fun createFocusedMediaTimeline(
room: MatrixRoom = FakeMatrixRoom(), room: MatrixRoom = FakeMatrixRoom(),
eventId: EventId = AN_EVENT_ID, eventId: EventId = AN_EVENT_ID,
initialMediaItem: MediaItem.Event = aMediaItemImage(), initialMediaItem: MediaItem.Event = aMediaItemImage(),
onlyPinnedEvent: Boolean = false,
) = FocusedMediaTimeline( ) = FocusedMediaTimeline(
room = room, room = room,
eventId = eventId, eventId = eventId,
initialMediaItem = initialMediaItem, initialMediaItem = initialMediaItem,
onlyPinnedEvents = onlyPinnedEvent,
) )
} }

View file

@ -28,22 +28,23 @@ class LiveMediaTimelineTest {
@Test @Test
fun `getTimeline returns the timeline provided by the room, then from cache`() = runTest { fun `getTimeline returns the timeline provided by the room, then from cache`() = runTest {
val mediaTimelineResult = lambdaRecorder<EventId?, Result<Timeline>> { val createTimelineResult = lambdaRecorder<EventId?, Boolean, Boolean, Result<Timeline>> { _, _, _ ->
Result.success(FakeTimeline()) Result.success(FakeTimeline())
} }
val room = FakeMatrixRoom( val room = FakeMatrixRoom(
mediaTimelineResult = mediaTimelineResult, createTimelineResult = createTimelineResult,
) )
val sut = createLiveMediaTimeline( val sut = createLiveMediaTimeline(
room = room, room = room,
) )
val timeline = sut.getTimeline() val timeline = sut.getTimeline()
assertThat(timeline.isSuccess).isTrue() assertThat(timeline.isSuccess).isTrue()
mediaTimelineResult.assertions().isCalledOnce().with(value(null)) createTimelineResult.assertions().isCalledOnce()
.with(value(null), value(false), value(true))
val timeline2 = sut.getTimeline() val timeline2 = sut.getTimeline()
assertThat(timeline2.isSuccess).isTrue() assertThat(timeline2.isSuccess).isTrue()
// No called another time // No called another time
mediaTimelineResult.assertions().isCalledOnce() createTimelineResult.assertions().isCalledOnce()
} }
private fun createLiveMediaTimeline( private fun createLiveMediaTimeline(

View file

@ -57,7 +57,7 @@ class TimelineMediaGalleryDataSourceTest {
val fakeTimeline = FakeTimeline() val fakeTimeline = FakeTimeline()
val sut = createTimelineMediaGalleryDataSource( val sut = createTimelineMediaGalleryDataSource(
room = FakeMatrixRoom( room = FakeMatrixRoom(
mediaTimelineResult = { Result.success(fakeTimeline) }, createTimelineResult = { _, _, _ -> Result.success(fakeTimeline) },
roomCoroutineScope = backgroundScope, roomCoroutineScope = backgroundScope,
) )
) )
@ -75,7 +75,7 @@ class TimelineMediaGalleryDataSourceTest {
runTest { runTest {
val sut = createTimelineMediaGalleryDataSource( val sut = createTimelineMediaGalleryDataSource(
room = FakeMatrixRoom( room = FakeMatrixRoom(
mediaTimelineResult = { Result.success(fakeTimeline) }, createTimelineResult = { _, _, _ -> Result.success(fakeTimeline) },
roomCoroutineScope = backgroundScope, roomCoroutineScope = backgroundScope,
) )
) )
@ -112,7 +112,7 @@ class TimelineMediaGalleryDataSourceTest {
} }
val sut = createTimelineMediaGalleryDataSource( val sut = createTimelineMediaGalleryDataSource(
room = FakeMatrixRoom( room = FakeMatrixRoom(
mediaTimelineResult = { Result.success(fakeTimeline) }, createTimelineResult = { _, _, _ -> Result.success(fakeTimeline) },
roomCoroutineScope = backgroundScope, roomCoroutineScope = backgroundScope,
) )
) )
@ -135,7 +135,7 @@ class TimelineMediaGalleryDataSourceTest {
} }
val sut = createTimelineMediaGalleryDataSource( val sut = createTimelineMediaGalleryDataSource(
room = FakeMatrixRoom( room = FakeMatrixRoom(
mediaTimelineResult = { Result.success(fakeTimeline) }, createTimelineResult = { _, _, _ -> Result.success(fakeTimeline) },
roomCoroutineScope = backgroundScope, roomCoroutineScope = backgroundScope,
) )
) )
@ -154,7 +154,7 @@ class TimelineMediaGalleryDataSourceTest {
fun `test - failing to load timeline should emit an error`() = runTest { fun `test - failing to load timeline should emit an error`() = runTest {
val sut = createTimelineMediaGalleryDataSource( val sut = createTimelineMediaGalleryDataSource(
room = FakeMatrixRoom( room = FakeMatrixRoom(
mediaTimelineResult = { Result.failure(AN_EXCEPTION) }, createTimelineResult = { _, _, _ -> Result.failure(AN_EXCEPTION) },
roomCoroutineScope = backgroundScope, roomCoroutineScope = backgroundScope,
) )
) )
@ -176,7 +176,7 @@ class TimelineMediaGalleryDataSourceTest {
) )
val sut = createTimelineMediaGalleryDataSource( val sut = createTimelineMediaGalleryDataSource(
room = FakeMatrixRoom( room = FakeMatrixRoom(
mediaTimelineResult = { Result.success(fakeTimeline) }, createTimelineResult = { _, _, _ -> Result.success(fakeTimeline) },
roomCoroutineScope = backgroundScope, roomCoroutineScope = backgroundScope,
) )
) )

View file

@ -52,7 +52,7 @@ class MediaGalleryPresenterTest {
), ),
room = FakeMatrixRoom( room = FakeMatrixRoom(
displayName = A_ROOM_NAME, displayName = A_ROOM_NAME,
mediaTimelineResult = { Result.success(FakeTimeline()) }, createTimelineResult = { _, _, _ -> Result.success(FakeTimeline()) },
) )
) )
presenter.test { presenter.test {
@ -71,7 +71,7 @@ class MediaGalleryPresenterTest {
val presenter = createMediaGalleryPresenter( val presenter = createMediaGalleryPresenter(
room = FakeMatrixRoom( room = FakeMatrixRoom(
displayName = A_ROOM_NAME, displayName = A_ROOM_NAME,
mediaTimelineResult = { Result.success(FakeTimeline()) }, createTimelineResult = { _, _, _ -> Result.success(FakeTimeline()) },
) )
) )
presenter.test { presenter.test {
@ -101,7 +101,7 @@ class MediaGalleryPresenterTest {
room = FakeMatrixRoom( room = FakeMatrixRoom(
sessionId = A_USER_ID, sessionId = A_USER_ID,
displayName = A_ROOM_NAME, displayName = A_ROOM_NAME,
mediaTimelineResult = { Result.success(FakeTimeline()) }, createTimelineResult = { _, _, _ -> Result.success(FakeTimeline()) },
canRedactOwnResult = { Result.success(canDeleteOwn) } canRedactOwnResult = { Result.success(canDeleteOwn) }
) )
) )
@ -144,7 +144,7 @@ class MediaGalleryPresenterTest {
room = FakeMatrixRoom( room = FakeMatrixRoom(
sessionId = A_USER_ID, sessionId = A_USER_ID,
displayName = A_ROOM_NAME, displayName = A_ROOM_NAME,
mediaTimelineResult = { Result.success(FakeTimeline()) }, createTimelineResult = { _, _, _ -> Result.success(FakeTimeline()) },
canRedactOtherResult = { Result.success(canDeleteOther) } canRedactOtherResult = { Result.success(canDeleteOther) }
) )
) )
@ -177,7 +177,7 @@ class MediaGalleryPresenterTest {
val presenter = createMediaGalleryPresenter( val presenter = createMediaGalleryPresenter(
room = FakeMatrixRoom( room = FakeMatrixRoom(
displayName = A_ROOM_NAME, displayName = A_ROOM_NAME,
mediaTimelineResult = { Result.success(FakeTimeline()) }, createTimelineResult = { _, _, _ -> Result.success(FakeTimeline()) },
) )
) )
presenter.test { presenter.test {
@ -244,7 +244,7 @@ class MediaGalleryPresenterTest {
) )
val presenter = createMediaGalleryPresenter( val presenter = createMediaGalleryPresenter(
room = FakeMatrixRoom( room = FakeMatrixRoom(
mediaTimelineResult = { Result.success(FakeTimeline()) }, createTimelineResult = { _, _, _ -> Result.success(FakeTimeline()) },
), ),
navigator = navigator, navigator = navigator,
) )

View file

@ -137,7 +137,7 @@ class MediaViewerDataSourceTest {
fun `test dataFlow with data galleryMode image`() = runTest { fun `test dataFlow with data galleryMode image`() = runTest {
val galleryDataSource = FakeMediaGalleryDataSource() val galleryDataSource = FakeMediaGalleryDataSource()
val sut = createMediaViewerDataSource( val sut = createMediaViewerDataSource(
mode = MediaViewerMode.TimelineImagesAndVideos, mode = MediaViewerMode.TimelineImagesAndVideos(timelineMode = Timeline.Mode.MEDIA),
galleryDataSource = galleryDataSource, galleryDataSource = galleryDataSource,
) )
sut.dataFlow().test { sut.dataFlow().test {
@ -159,7 +159,7 @@ class MediaViewerDataSourceTest {
fun `test dataFlow with data galleryMode files`() = runTest { fun `test dataFlow with data galleryMode files`() = runTest {
val galleryDataSource = FakeMediaGalleryDataSource() val galleryDataSource = FakeMediaGalleryDataSource()
val sut = createMediaViewerDataSource( val sut = createMediaViewerDataSource(
mode = MediaViewerMode.TimelineFilesAndAudios, mode = MediaViewerMode.TimelineFilesAndAudios(timelineMode = Timeline.Mode.MEDIA),
galleryDataSource = galleryDataSource, galleryDataSource = galleryDataSource,
) )
sut.dataFlow().test { sut.dataFlow().test {
@ -265,7 +265,7 @@ class MediaViewerDataSourceTest {
} }
private fun TestScope.createMediaViewerDataSource( private fun TestScope.createMediaViewerDataSource(
mode: MediaViewerMode = MediaViewerMode.TimelineImagesAndVideos, mode: MediaViewerMode = MediaViewerMode.TimelineImagesAndVideos(timelineMode = Timeline.Mode.MEDIA),
galleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource(), galleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource(),
mediaLoader: MatrixMediaLoader = FakeMatrixMediaLoader(), mediaLoader: MatrixMediaLoader = FakeMatrixMediaLoader(),
localMediaFactory: LocalMediaFactory = FakeLocalMediaFactory(mockMediaUrl), localMediaFactory: LocalMediaFactory = FakeLocalMediaFactory(mockMediaUrl),

View file

@ -519,7 +519,7 @@ class MediaViewerPresenterTest {
@Test @Test
fun `present - snackbar displayed when there is no more items forward images and videos`() { fun `present - snackbar displayed when there is no more items forward images and videos`() {
`present - snackbar displayed when there is no more items forward`( `present - snackbar displayed when there is no more items forward`(
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos, mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(timelineMode = Timeline.Mode.MEDIA),
expectedSnackbarResId = R.string.screen_media_details_no_more_media_to_show, expectedSnackbarResId = R.string.screen_media_details_no_more_media_to_show,
) )
} }
@ -527,7 +527,7 @@ class MediaViewerPresenterTest {
@Test @Test
fun `present - snackbar displayed when there is no more items forward files and audio`() { fun `present - snackbar displayed when there is no more items forward files and audio`() {
`present - snackbar displayed when there is no more items forward`( `present - snackbar displayed when there is no more items forward`(
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios, mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode = Timeline.Mode.MEDIA),
expectedSnackbarResId = R.string.screen_media_details_no_more_files_to_show, expectedSnackbarResId = R.string.screen_media_details_no_more_files_to_show,
) )
} }
@ -547,7 +547,7 @@ class MediaViewerPresenterTest {
awaitFirstItem() awaitFirstItem()
mediaGalleryDataSource.emitGroupedMediaItems( mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success( AsyncData.Success(
if (mode == MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) { if (mode is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) {
GroupedMediaItems( GroupedMediaItems(
imageAndVideoItems = persistentListOf(), imageAndVideoItems = persistentListOf(),
fileItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator), fileItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator),
@ -568,7 +568,7 @@ class MediaViewerPresenterTest {
// data source claims that there is no more items to load forward // data source claims that there is no more items to load forward
mediaGalleryDataSource.emitGroupedMediaItems( mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success( AsyncData.Success(
if (mode == MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) { if (mode is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) {
GroupedMediaItems( GroupedMediaItems(
imageAndVideoItems = persistentListOf(), imageAndVideoItems = persistentListOf(),
fileItems = persistentListOf(anImage, aBackwardLoadingIndicator), fileItems = persistentListOf(anImage, aBackwardLoadingIndicator),
@ -590,7 +590,7 @@ class MediaViewerPresenterTest {
@Test @Test
fun `present - snackbar displayed when there is no more items backward images and videos`() { fun `present - snackbar displayed when there is no more items backward images and videos`() {
`present - snackbar displayed when there is no more items backward`( `present - snackbar displayed when there is no more items backward`(
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos, mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(timelineMode = Timeline.Mode.MEDIA),
expectedSnackbarResId = R.string.screen_media_details_no_more_media_to_show, expectedSnackbarResId = R.string.screen_media_details_no_more_media_to_show,
) )
} }
@ -598,7 +598,7 @@ class MediaViewerPresenterTest {
@Test @Test
fun `present - snackbar displayed when there is no more items backward files and audio`() { fun `present - snackbar displayed when there is no more items backward files and audio`() {
`present - snackbar displayed when there is no more items backward`( `present - snackbar displayed when there is no more items backward`(
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios, mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode = Timeline.Mode.MEDIA),
expectedSnackbarResId = R.string.screen_media_details_no_more_files_to_show, expectedSnackbarResId = R.string.screen_media_details_no_more_files_to_show,
) )
} }
@ -618,7 +618,7 @@ class MediaViewerPresenterTest {
awaitFirstItem() awaitFirstItem()
mediaGalleryDataSource.emitGroupedMediaItems( mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success( AsyncData.Success(
if (mode == MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) { if (mode is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) {
GroupedMediaItems( GroupedMediaItems(
imageAndVideoItems = persistentListOf(), imageAndVideoItems = persistentListOf(),
fileItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator), fileItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator),
@ -640,7 +640,7 @@ class MediaViewerPresenterTest {
// data source claims that there is no more items to load backward // data source claims that there is no more items to load backward
mediaGalleryDataSource.emitGroupedMediaItems( mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success( AsyncData.Success(
if (mode == MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) { if (mode is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) {
GroupedMediaItems( GroupedMediaItems(
imageAndVideoItems = persistentListOf(), imageAndVideoItems = persistentListOf(),
fileItems = persistentListOf(aForwardLoadingIndicator, anImage), fileItems = persistentListOf(aForwardLoadingIndicator, anImage),

View file

@ -84,6 +84,27 @@ class EnsureCalledOnceWithTwoParams<T, U>(
} }
} }
class EnsureCalledOnceWithTwoParamsAndResult<T, U, R>(
private val expectedParam1: T,
private val expectedParam2: U,
private val result: R,
) : (T, U) -> R {
private var counter = 0
override fun invoke(p1: T, p2: U): R {
if (p1 != expectedParam1 || p2 != expectedParam2) {
throw AssertionError("Expected to be called with $expectedParam1 and $expectedParam2, but was called with $p1 and $p2")
}
counter++
return result
}
fun assertSuccess() {
if (counter != 1) {
throw AssertionError("Expected to be called once, but was called $counter times")
}
}
}
/** /**
* Shortcut for [<T, R> ensureCalledOnceWithParam] with Unit result. * Shortcut for [<T, R> ensureCalledOnceWithParam] with Unit result.
*/ */

View file

@ -32,3 +32,9 @@ class EnsureNeverCalledWithTwoParams<T, U> : (T, U) -> Unit {
lambdaError("Should not be called and is called with $p1 and $p2") lambdaError("Should not be called and is called with $p1 and $p2")
} }
} }
class EnsureNeverCalledWithTwoParamsAndResult<T, U, R> : (T, U) -> R {
override fun invoke(p1: T, p2: U): R {
lambdaError("Should not be called and is called with $p1 and $p2")
}
}