Media Browser: Listen to the timeline in the preview screen (#3707)

* Listen to the timeline to load more items in the media preview screen.

* Fix the view model tests.

* Fix tests
This commit is contained in:
Doug
2025-01-27 16:22:49 +00:00
committed by GitHub
parent b477a32d2e
commit c6338064b6
9 changed files with 710 additions and 326 deletions
@@ -0,0 +1,171 @@
//
// 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.
//
@testable import ElementX
import QuickLook
import XCTest
@MainActor
class TimelineMediaPreviewDataSourceTests: XCTestCase {
var initialMediaItems: [EventBasedMessageTimelineItemProtocol]!
var initialMediaViewStates: [RoomTimelineItemViewState]!
let initialItemIndex = 2
var initialPadding = 100
let previewController = QLPreviewController()
override func setUp() {
initialMediaItems = newChunk()
initialMediaViewStates = initialMediaItems.map { RoomTimelineItemViewState(item: $0, groupStyle: .single) }
}
func testInitialItems() -> TimelineMediaPreviewDataSource {
// Given a data source built with the initial items.
let dataSource = TimelineMediaPreviewDataSource(itemViewStates: initialMediaViewStates,
initialItem: initialMediaItems[initialItemIndex],
initialPadding: initialPadding)
// When the preview controller displays the data.
let previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
let displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem
// Then the preview controller should be showing the initial item and the data source should reflect this.
XCTAssertEqual(dataSource.initialItemIndex, initialItemIndex + initialPadding, "The initial item index should be padded for the preview controller.")
XCTAssertEqual(displayedItem?.id, initialMediaItems[initialItemIndex].id, "The displayed item should be the initial item.")
XCTAssertEqual(dataSource.currentItem?.id, initialMediaItems[initialItemIndex].id, "The current item should also be the initial item.")
XCTAssertEqual(dataSource.previewItems.count, initialMediaViewStates.count, "The initial count of preview items should be correct.")
XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The initial item count should be padded for the preview controller.")
return dataSource
}
func testCurrentUpdateItem() {
// Given a data source built with the initial items.
let dataSource = TimelineMediaPreviewDataSource(itemViewStates: initialMediaViewStates, initialItem: initialMediaItems[initialItemIndex])
// When a different item is displayed.
let previewItem = dataSource.previewController(previewController, previewItemAt: 1 + initialPadding) as? TimelineMediaPreviewItem
XCTAssertNotNil(previewItem, "A preview item should be found.")
dataSource.updateCurrentItem(previewItem)
// Then the data source should reflect the change of item.
XCTAssertEqual(dataSource.currentItem?.id, previewItem?.id, "The displayed item should be the initial item.")
// When a loading item is displayed.
let loadingItem = dataSource.previewController(previewController, previewItemAt: initialPadding - 1) as? TimelineMediaPreviewLoadingItem
XCTAssertNotNil(loadingItem, "A loading item should be be returned.")
dataSource.updateCurrentItem(nil)
// Then the data source should indicate that no item is being displayed.
XCTAssertNil(dataSource.currentItem, "The current item should be nil.")
}
func testUpdatedItems() async throws {
// Given a data source built with the initial items.
let dataSource = testInitialItems()
// When one of the items changes but no pagination has occurred.
let deferred = deferFailure(dataSource.previewItemsPaginationPublisher, timeout: 1) { _ in true }
dataSource.updatePreviewItems(itemViewStates: initialMediaViewStates)
// Then no pagination should be detected and none of the data should have changed.
try await deferred.fulfill()
let previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
let displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem
XCTAssertEqual(displayedItem?.id, initialMediaItems[initialItemIndex].id, "The displayed item should not change.")
XCTAssertEqual(dataSource.currentItem?.id, initialMediaItems[initialItemIndex].id, "The current item should not change.")
XCTAssertEqual(dataSource.previewItems.count, initialMediaViewStates.count, "The number of items should not change.")
XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The padded number of items should not change.")
}
func testPagination() async throws {
// Given a data source built with the initial items.
let dataSource = testInitialItems()
// When more items are loaded in a back pagination.
var deferred = deferFulfillment(dataSource.previewItemsPaginationPublisher) { _ in true }
let backPaginationChunk = newChunk().map { RoomTimelineItemViewState(item: $0, groupStyle: .single) }
var newViewStates = backPaginationChunk + initialMediaViewStates
dataSource.updatePreviewItems(itemViewStates: newViewStates)
// Then the new items should be added but the displayed item should not change or move in the array.
try await deferred.fulfill()
XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.")
var previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
var displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem
XCTAssertEqual(displayedItem?.id, initialMediaItems[initialItemIndex].id, "The displayed item should not change.")
XCTAssertEqual(dataSource.currentItem?.id, initialMediaItems[initialItemIndex].id, "The current item should not change.")
XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change")
// When more items are loaded in a forward pagination or sync.
deferred = deferFulfillment(dataSource.previewItemsPaginationPublisher) { _ in true }
let forwardPaginationChunk = newChunk().map { RoomTimelineItemViewState(item: $0, groupStyle: .single) }
newViewStates += forwardPaginationChunk
dataSource.updatePreviewItems(itemViewStates: newViewStates)
// Then the new items should be added but the displayed item should not change or move in the array.
try await deferred.fulfill()
XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.")
previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem
XCTAssertEqual(displayedItem?.id, initialMediaItems[initialItemIndex].id, "The displayed item should not change.")
XCTAssertEqual(dataSource.currentItem?.id, initialMediaItems[initialItemIndex].id, "The current item should not change.")
XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change")
}
func testPaginationLimits() async throws {
// Given a data source with a small amount of padding remaining.
initialPadding = 2
let dataSource = testInitialItems()
// When paginating backwards by more than the available padding.
var deferred = deferFulfillment(dataSource.previewItemsPaginationPublisher) { _ in true }
let backPaginationChunk = newChunk().map { RoomTimelineItemViewState(item: $0, groupStyle: .single) }
var newViewStates = backPaginationChunk + initialMediaViewStates
XCTAssertTrue(newViewStates.count > initialPadding)
dataSource.updatePreviewItems(itemViewStates: newViewStates)
// Then all the items should be added but the preview-able count shouldn't grow and displayed item should not change or move.
try await deferred.fulfill()
XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.")
var previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
var displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem
XCTAssertEqual(displayedItem?.id, initialMediaItems[initialItemIndex].id, "The displayed item should not change.")
XCTAssertEqual(dataSource.currentItem?.id, initialMediaItems[initialItemIndex].id, "The current item should not change.")
XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change")
// When paginating forwards by more than the available padding.
deferred = deferFulfillment(dataSource.previewItemsPaginationPublisher) { _ in true }
let forwardPaginationChunk = newChunk().map { RoomTimelineItemViewState(item: $0, groupStyle: .single) }
newViewStates += forwardPaginationChunk
dataSource.updatePreviewItems(itemViewStates: newViewStates)
// Then all the items should be added but the preview-able count shouldn't grow and displayed item should not change or move.
try await deferred.fulfill()
XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.")
previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem
XCTAssertEqual(displayedItem?.id, initialMediaItems[initialItemIndex].id, "The displayed item should not change.")
XCTAssertEqual(dataSource.currentItem?.id, initialMediaItems[initialItemIndex].id, "The current item should not change.")
XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change")
}
// MARK: Helpers
func newChunk() -> [EventBasedMessageTimelineItemProtocol] {
RoomTimelineItemFixtures.mediaChunk
.compactMap { $0 as? EventBasedMessageTimelineItemProtocol }
.filter(\.supportsMediaCaption) // Voice messages can't be previewed (and don't support captions).
}
}
@@ -9,6 +9,7 @@
import Combine
import MatrixRustSDK
import QuickLook
import SwiftUI
import XCTest
@@ -24,7 +25,7 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
// Given a fresh view model.
setupViewModel()
XCTAssertFalse(mediaProvider.loadFileFromSourceFilenameCalled)
XCTAssertEqual(context.viewState.currentItem, context.viewState.previewItems[0])
XCTAssertEqual(context.viewState.currentItem, context.viewState.dataSource.previewItems[0])
XCTAssertNotNil(context.viewState.currentItemActions)
// When the preview controller sets the current item.
@@ -32,27 +33,32 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
// Then the view model should load the item and update its view state.
XCTAssertTrue(mediaProvider.loadFileFromSourceFilenameCalled)
XCTAssertEqual(context.viewState.currentItem, context.viewState.previewItems[0])
XCTAssertEqual(context.viewState.currentItem, context.viewState.dataSource.previewItems[0])
XCTAssertNotNil(context.viewState.currentItemActions)
}
func testLoadingItemFailure() async throws {
// Given a fresh view model.
setupViewModel()
guard let currentItem = context.viewState.currentItem else {
XCTFail("There should be a current item")
return
}
XCTAssertFalse(mediaProvider.loadFileFromSourceFilenameCalled)
XCTAssertEqual(context.viewState.currentItem, context.viewState.previewItems[0])
XCTAssertNil(context.viewState.currentItem.downloadError)
XCTAssertEqual(currentItem, context.viewState.dataSource.previewItems[0])
XCTAssertNil(currentItem.downloadError)
// When the preview controller sets an item that fails to load.
mediaProvider.loadFileFromSourceFilenameClosure = { _, _ in .failure(.failedRetrievingFile) }
let failure = deferFailure(viewModel.state.fileLoadedPublisher, timeout: 1) { _ in true }
context.send(viewAction: .updateCurrentItem(context.viewState.previewItems[0]))
context.send(viewAction: .updateCurrentItem(context.viewState.dataSource.previewItems[0]))
try await failure.fulfill()
// Then the view model should load the item and update its view state.
XCTAssertTrue(mediaProvider.loadFileFromSourceFilenameCalled)
XCTAssertEqual(context.viewState.currentItem, context.viewState.previewItems[0])
XCTAssertNotNil(context.viewState.currentItem.downloadError)
XCTAssertEqual(currentItem, context.viewState.dataSource.previewItems[0])
XCTAssertNotNil(currentItem.downloadError)
}
func testSwipingBetweenItems() async throws {
@@ -61,21 +67,57 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
// When swiping to another item.
let deferred = deferFulfillment(viewModel.state.fileLoadedPublisher) { _ in true }
context.send(viewAction: .updateCurrentItem(context.viewState.previewItems[1]))
context.send(viewAction: .updateCurrentItem(context.viewState.dataSource.previewItems[1]))
try await deferred.fulfill()
// Then the view model should load the item and update its view state.
XCTAssertEqual(mediaProvider.loadFileFromSourceFilenameCallsCount, 2)
XCTAssertEqual(context.viewState.currentItem, context.viewState.previewItems[1])
XCTAssertEqual(context.viewState.currentItem, context.viewState.dataSource.previewItems[1])
// When swiping back to the first item.
let failure = deferFailure(viewModel.state.fileLoadedPublisher, timeout: 1) { _ in true }
context.send(viewAction: .updateCurrentItem(context.viewState.previewItems[0]))
context.send(viewAction: .updateCurrentItem(context.viewState.dataSource.previewItems[0]))
try await failure.fulfill()
// Then the view model should not need to load the item, but should still update its view state.
XCTAssertEqual(mediaProvider.loadFileFromSourceFilenameCallsCount, 2)
XCTAssertEqual(context.viewState.currentItem, context.viewState.previewItems[0])
XCTAssertEqual(context.viewState.currentItem, context.viewState.dataSource.previewItems[0])
}
func testLoadingMoreItem() async throws {
// Given a view model with a loaded item.
try await testLoadingItem()
// When swiping to a "loading more" item.
let deferred = deferFailure(viewModel.state.fileLoadedPublisher, timeout: 1) { _ in true }
context.send(viewAction: .updateCurrentItem(nil))
try await deferred.fulfill()
// Then there should no longer be a media preview and no attempt should be made to load one.
XCTAssertEqual(mediaProvider.loadFileFromSourceFilenameCallsCount, 1)
XCTAssertNil(context.viewState.currentItem)
}
func testPagination() async throws {
// Given a view model with a loaded item.
try await testLoadingItem()
XCTAssertEqual(context.viewState.dataSource.previewItems.count, 3)
// When more items are added via a back pagination.
let deferred = deferFulfillment(context.viewState.dataSource.previewItemsPaginationPublisher) { _ in true }
timelineController.backPaginationResponses.append(makeItems())
_ = await timelineController.paginateBackwards(requestSize: 20)
try await deferred.fulfill()
// And the preview controller attempts to update the current item (now at a new index in the array but it hasn't changed in the data source).
mediaProvider.loadFileFromSourceFilenameClosure = { _, _ in .failure(.failedRetrievingFile) }
let failure = deferFailure(viewModel.state.fileLoadedPublisher, timeout: 1) { _ in true }
context.send(viewAction: .updateCurrentItem(context.viewState.dataSource.previewItems[3]))
try await failure.fulfill()
// Then the current item shouldn't need to be reloaded.
XCTAssertEqual(context.viewState.dataSource.previewItems.count, 6)
XCTAssertEqual(mediaProvider.loadFileFromSourceFilenameCallsCount, 1)
}
func testViewInRoomTimeline() async throws {
@@ -83,7 +125,11 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
try await testLoadingItem()
// When choosing to view the current item in the timeline.
let item = context.viewState.currentItem
guard let item = context.viewState.currentItem else {
XCTFail("There should be a current item.")
return
}
let deferred = deferFulfillment(viewModel.actions) { $0 == .viewInRoomTimeline(item.id) }
context.send(viewAction: .menuAction(.viewInRoomTimeline, item: item))
@@ -126,28 +172,35 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
func testSaveImage() async throws {
// Given a view model with a loaded image.
try await testLoadingItem()
XCTAssertEqual(viewModel.state.currentItem.contentType, "JPEG image")
guard let currentItem = context.viewState.currentItem else {
XCTFail("There should be a current item")
return
}
XCTAssertEqual(currentItem.contentType, "JPEG image")
// When choosing to save the image.
let item = context.viewState.currentItem
context.send(viewAction: .menuAction(.save, item: item))
context.send(viewAction: .menuAction(.save, item: currentItem))
try await Task.sleep(for: .seconds(0.5))
// Then the image should be saved as a photo to the user's photo library.
XCTAssertTrue(photoLibraryManager.addResourceAtCalled)
XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.type, .photo)
XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.url, item.fileHandle?.url)
XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.url, currentItem.fileHandle?.url)
}
func testSaveImageWithoutAuthorization() async throws {
// Given a view model with a loaded image where the user has denied access to the photo library.
setupViewModel(photoLibraryAuthorizationDenied: true)
try await loadInitialItem()
XCTAssertEqual(viewModel.state.currentItem.contentType, "JPEG image")
guard let currentItem = context.viewState.currentItem else {
XCTFail("There should be a current item")
return
}
XCTAssertEqual(currentItem.contentType, "JPEG image")
// When choosing to save the image.
let deferred = deferFulfillment(context.$viewState) { $0.bindings.alertInfo != nil }
context.send(viewAction: .menuAction(.save, item: context.viewState.currentItem))
context.send(viewAction: .menuAction(.save, item: currentItem))
try await deferred.fulfill()
// Then the user should be prompted to allow access.
@@ -159,34 +212,40 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
// Given a view model with a loaded video.
setupViewModel(initialItemIndex: 1)
try await loadInitialItem()
XCTAssertEqual(viewModel.state.currentItem.contentType, "MPEG-4 movie")
guard let currentItem = context.viewState.currentItem else {
XCTFail("There should be a current item")
return
}
XCTAssertEqual(currentItem.contentType, "MPEG-4 movie")
// When choosing to save the video.
let item = context.viewState.currentItem
context.send(viewAction: .menuAction(.save, item: item))
context.send(viewAction: .menuAction(.save, item: currentItem))
try await Task.sleep(for: .seconds(0.5))
// Then the video should be saved as a video in the user's photo library.
XCTAssertTrue(photoLibraryManager.addResourceAtCalled)
XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.type, .video)
XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.url, item.fileHandle?.url)
XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.url, currentItem.fileHandle?.url)
}
func testSaveFile() async throws {
// Given a view model with a loaded file.
setupViewModel(initialItemIndex: 2)
try await loadInitialItem()
XCTAssertEqual(viewModel.state.currentItem.contentType, "PDF document")
guard let currentItem = context.viewState.currentItem else {
XCTFail("There should be a current item")
return
}
XCTAssertEqual(currentItem.contentType, "PDF document")
// When choosing to save the file.
let item = context.viewState.currentItem
context.send(viewAction: .menuAction(.save, item: item))
context.send(viewAction: .menuAction(.save, item: currentItem))
try await Task.sleep(for: .seconds(0.5))
// Then the binding should be set for the user to export the file to their specified location.
XCTAssertFalse(photoLibraryManager.addResourceAtCalled)
XCTAssertNotNil(context.fileToExport)
XCTAssertEqual(context.fileToExport?.url, item.fileHandle?.url)
XCTAssertEqual(context.fileToExport?.url, currentItem.fileHandle?.url)
}
func testDismiss() async throws {
@@ -205,20 +264,27 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
private func loadInitialItem() async throws {
let deferred = deferFulfillment(viewModel.state.fileLoadedPublisher) { _ in true }
context.send(viewAction: .updateCurrentItem(context.viewState.previewItems[context.viewState.initialItemIndex]))
let initialItem = context.viewState.dataSource.previewController(QLPreviewController(),
previewItemAt: context.viewState.dataSource.initialItemIndex)
guard let initialPreviewItem = initialItem as? TimelineMediaPreviewItem else {
XCTFail("1234")
return
}
context.send(viewAction: .updateCurrentItem(initialPreviewItem))
try await deferred.fulfill()
}
@Namespace private var testNamespace
private func setupViewModel(initialItemIndex: Int = 0, photoLibraryAuthorizationDenied: Bool = false) {
let initialItems = makeItems()
timelineController = MockRoomTimelineController(timelineKind: .media(.mediaFilesScreen))
timelineController.timelineItems = items
timelineController.timelineItems = initialItems
mediaProvider = MediaProviderMock(configuration: .init())
photoLibraryManager = PhotoLibraryManagerMock(.init(authorizationDenied: photoLibraryAuthorizationDenied))
viewModel = TimelineMediaPreviewViewModel(context: .init(item: items[initialItemIndex],
viewModel = TimelineMediaPreviewViewModel(context: .init(item: initialItems[initialItemIndex],
viewModel: TimelineViewModel.mock(timelineKind: .media(.mediaFilesScreen),
timelineController: timelineController),
namespace: testNamespace),
@@ -228,41 +294,43 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
appMediator: AppMediatorMock())
}
private let items: [EventBasedMessageTimelineItemProtocol] = [
ImageRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: "", displayName: "Sally Sanderson"),
content: .init(filename: "Amazing image.jpeg",
caption: "A caption goes right here.",
imageInfo: .mockImage,
thumbnailInfo: .mockThumbnail,
contentType: .jpeg)),
VideoRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: ""),
content: .init(filename: "Super video.mp4",
videoInfo: .mockVideo,
thumbnailInfo: .mockThumbnail,
contentType: .mpeg4Movie)),
FileRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: ""),
content: .init(filename: "Important file.pdf",
source: try? .init(url: .mockMXCFile, mimeType: "document/pdf"),
fileSize: 2453,
thumbnailSource: nil,
contentType: .pdf))
]
private func makeItems() -> [EventBasedMessageTimelineItemProtocol] {
[
ImageRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: "", displayName: "Sally Sanderson"),
content: .init(filename: "Amazing image.jpeg",
caption: "A caption goes right here.",
imageInfo: .mockImage,
thumbnailInfo: .mockThumbnail,
contentType: .jpeg)),
VideoRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: ""),
content: .init(filename: "Super video.mp4",
videoInfo: .mockVideo,
thumbnailInfo: .mockThumbnail,
contentType: .mpeg4Movie)),
FileRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: ""),
content: .init(filename: "Important file.pdf",
source: try? .init(url: .mockMXCFile, mimeType: "document/pdf"),
fileSize: 2453,
thumbnailSource: nil,
contentType: .pdf))
]
}
}