Detect the timeline start/end when swiping through media files. (#3714)

This commit is contained in:
Doug 2025-01-29 15:07:23 +00:00 committed by GitHub
parent df997ad251
commit d412c10352
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 402 additions and 302 deletions

View File

@ -414,6 +414,8 @@
"screen_knock_requests_list_title" = "Requests to join"; "screen_knock_requests_list_title" = "Requests to join";
"screen_media_details_file_format" = "File format"; "screen_media_details_file_format" = "File format";
"screen_media_details_filename" = "File name"; "screen_media_details_filename" = "File name";
"screen_media_details_no_more_files_to_show" = "No more files to show";
"screen_media_details_no_more_media_to_show" = "No more media to show";
"screen_media_details_redact_confirmation_message" = "This file will be removed from the room and members wont have access to it."; "screen_media_details_redact_confirmation_message" = "This file will be removed from the room and members wont have access to it.";
"screen_media_details_redact_confirmation_title" = "Delete file?"; "screen_media_details_redact_confirmation_title" = "Delete file?";
"screen_media_details_uploaded_by" = "Uploaded by"; "screen_media_details_uploaded_by" = "Uploaded by";
@ -478,8 +480,6 @@
"screen_timeline_item_menu_send_failure_changed_identity" = "Message not sent because %1$@s verified identity has changed."; "screen_timeline_item_menu_send_failure_changed_identity" = "Message not sent because %1$@s verified identity has changed.";
"screen_timeline_item_menu_send_failure_unsigned_device" = "Message not sent because %1$@ has not verified all devices."; "screen_timeline_item_menu_send_failure_unsigned_device" = "Message not sent because %1$@ has not verified all devices.";
"screen_timeline_item_menu_send_failure_you_unsigned_device" = "Message not sent because you have not verified one or more of your devices."; "screen_timeline_item_menu_send_failure_you_unsigned_device" = "Message not sent because you have not verified one or more of your devices.";
"screen.media_details.no_more_files_to_show" = "No more files to show";
"screen.media_details.no_more_media_to_show" = "No more media to show";
"screen_account_provider_form_hint" = "Homeserver address"; "screen_account_provider_form_hint" = "Homeserver address";
"screen_account_provider_form_notice" = "Enter a search term or a domain address."; "screen_account_provider_form_notice" = "Enter a search term or a domain address.";
"screen_account_provider_form_subtitle" = "Search for a company, community, or private server."; "screen_account_provider_form_subtitle" = "Search for a company, community, or private server.";

View File

@ -414,6 +414,8 @@
"screen_knock_requests_list_title" = "Requests to join"; "screen_knock_requests_list_title" = "Requests to join";
"screen_media_details_file_format" = "File format"; "screen_media_details_file_format" = "File format";
"screen_media_details_filename" = "File name"; "screen_media_details_filename" = "File name";
"screen_media_details_no_more_files_to_show" = "No more files to show";
"screen_media_details_no_more_media_to_show" = "No more media to show";
"screen_media_details_redact_confirmation_message" = "This file will be removed from the room and members wont have access to it."; "screen_media_details_redact_confirmation_message" = "This file will be removed from the room and members wont have access to it.";
"screen_media_details_redact_confirmation_title" = "Delete file?"; "screen_media_details_redact_confirmation_title" = "Delete file?";
"screen_media_details_uploaded_by" = "Uploaded by"; "screen_media_details_uploaded_by" = "Uploaded by";
@ -478,8 +480,6 @@
"screen_timeline_item_menu_send_failure_changed_identity" = "Message not sent because %1$@s verified identity has changed."; "screen_timeline_item_menu_send_failure_changed_identity" = "Message not sent because %1$@s verified identity has changed.";
"screen_timeline_item_menu_send_failure_unsigned_device" = "Message not sent because %1$@ has not verified all devices."; "screen_timeline_item_menu_send_failure_unsigned_device" = "Message not sent because %1$@ has not verified all devices.";
"screen_timeline_item_menu_send_failure_you_unsigned_device" = "Message not sent because you have not verified one or more of your devices."; "screen_timeline_item_menu_send_failure_you_unsigned_device" = "Message not sent because you have not verified one or more of your devices.";
"screen.media_details.no_more_files_to_show" = "No more files to show";
"screen.media_details.no_more_media_to_show" = "No more media to show";
"screen_account_provider_form_hint" = "Homeserver address"; "screen_account_provider_form_hint" = "Homeserver address";
"screen_account_provider_form_notice" = "Enter a search term or a domain address."; "screen_account_provider_form_notice" = "Enter a search term or a domain address.";
"screen_account_provider_form_subtitle" = "Search for a company, community, or private server."; "screen_account_provider_form_subtitle" = "Search for a company, community, or private server.";

View File

@ -1458,6 +1458,10 @@ internal enum L10n {
internal static var screenMediaDetailsFileFormat: String { return L10n.tr("Localizable", "screen_media_details_file_format") } internal static var screenMediaDetailsFileFormat: String { return L10n.tr("Localizable", "screen_media_details_file_format") }
/// File name /// File name
internal static var screenMediaDetailsFilename: String { return L10n.tr("Localizable", "screen_media_details_filename") } internal static var screenMediaDetailsFilename: String { return L10n.tr("Localizable", "screen_media_details_filename") }
/// No more files to show
internal static var screenMediaDetailsNoMoreFilesToShow: String { return L10n.tr("Localizable", "screen_media_details_no_more_files_to_show") }
/// No more media to show
internal static var screenMediaDetailsNoMoreMediaToShow: String { return L10n.tr("Localizable", "screen_media_details_no_more_media_to_show") }
/// This file will be removed from the room and members wont have access to it. /// This file will be removed from the room and members wont have access to it.
internal static var screenMediaDetailsRedactConfirmationMessage: String { return L10n.tr("Localizable", "screen_media_details_redact_confirmation_message") } internal static var screenMediaDetailsRedactConfirmationMessage: String { return L10n.tr("Localizable", "screen_media_details_redact_confirmation_message") }
/// Delete file? /// Delete file?
@ -2804,15 +2808,6 @@ internal enum L10n {
/// You /// You
internal static var you: String { return L10n.tr("Localizable", "common.you") } internal static var you: String { return L10n.tr("Localizable", "common.you") }
} }
internal enum Screen {
internal enum MediaDetails {
/// No more files to show
internal static var noMoreFilesToShow: String { return L10n.tr("Localizable", "screen.media_details.no_more_files_to_show") }
/// No more media to show
internal static var noMoreMediaToShow: String { return L10n.tr("Localizable", "screen.media_details.no_more_media_to_show") }
}
}
} }
// swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length
// swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces // swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces

View File

@ -19,7 +19,7 @@ import QuickLook
/// in the data. /// in the data.
class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource { class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource {
/// All of the items in the timeline that can be previewed. /// All of the items in the timeline that can be previewed.
private(set) var previewItems: [TimelineMediaPreviewItem] private(set) var previewItems: [TimelineMediaPreviewItem.Media]
let previewItemsPaginationPublisher = PassthroughSubject<Void, Never>() let previewItemsPaginationPublisher = PassthroughSubject<Void, Never>()
private let initialItem: EventBasedMessageTimelineItemProtocol private let initialItem: EventBasedMessageTimelineItemProtocol
@ -27,30 +27,37 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource {
let initialItemIndex: Int let initialItemIndex: Int
/// The media item that is currently being previewed. /// The media item that is currently being previewed.
private(set) var currentItem: TimelineMediaPreviewItem? private(set) var currentItem: TimelineMediaPreviewItem
private var backwardPadding: Int private var backwardPadding: Int
private var forwardPadding: Int private var forwardPadding: Int
init(itemViewStates: [RoomTimelineItemViewState], initialItem: EventBasedMessageTimelineItemProtocol, initialPadding: Int = 100) { var paginationState: PaginationState
previewItems = itemViewStates.compactMap(TimelineMediaPreviewItem.init)
init(itemViewStates: [RoomTimelineItemViewState],
initialItem: EventBasedMessageTimelineItemProtocol,
initialPadding: Int = 100,
paginationState: PaginationState) {
previewItems = itemViewStates.compactMap(TimelineMediaPreviewItem.Media.init)
self.initialItem = initialItem self.initialItem = initialItem
let initialItemArrayIndex = previewItems.firstIndex { $0.id == initialItem.id } ?? 0 let initialItemArrayIndex = previewItems.firstIndex { $0.id == initialItem.id } ?? 0
initialItemIndex = initialItemArrayIndex + initialPadding initialItemIndex = initialItemArrayIndex + initialPadding
currentItem = previewItems[initialItemArrayIndex] currentItem = .media(previewItems[initialItemArrayIndex])
backwardPadding = initialPadding backwardPadding = initialPadding
forwardPadding = initialPadding forwardPadding = initialPadding
self.paginationState = paginationState
} }
func updateCurrentItem(_ item: TimelineMediaPreviewItem?) { func updateCurrentItem(_ item: TimelineMediaPreviewItem) {
currentItem = item currentItem = item
} }
func updatePreviewItems(itemViewStates: [RoomTimelineItemViewState]) { func updatePreviewItems(itemViewStates: [RoomTimelineItemViewState]) {
let newItems: [TimelineMediaPreviewItem] = itemViewStates.compactMap { itemViewState in let newItems: [TimelineMediaPreviewItem.Media] = itemViewStates.compactMap { itemViewState in
guard let newItem = TimelineMediaPreviewItem(roomTimelineItemViewState: itemViewState) else { return nil } guard let newItem = TimelineMediaPreviewItem.Media(roomTimelineItemViewState: itemViewState) else { return nil }
// If an item already exists use that instead to preserve the file handle, download error etc. // If an item already exists use that instead to preserve the file handle, download error etc.
if let oldItem = previewItems.first(where: { $0.id == newItem.id }) { if let oldItem = previewItems.first(where: { $0.id == newItem.id }) {
@ -91,6 +98,9 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource {
// MARK: - QLPreviewControllerDataSource // MARK: - QLPreviewControllerDataSource
var firstPreviewItemIndex: Int { backwardPadding }
var lastPreviewItemIndex: Int { backwardPadding + previewItems.count - 1 }
func numberOfPreviewItems(in controller: QLPreviewController) -> Int { func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
previewItems.count + backwardPadding + forwardPadding previewItems.count + backwardPadding + forwardPadding
} }
@ -98,18 +108,24 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource {
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> any QLPreviewItem { func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> any QLPreviewItem {
let arrayIndex = index - backwardPadding let arrayIndex = index - backwardPadding
if arrayIndex >= 0, arrayIndex < previewItems.count { if index < firstPreviewItemIndex {
return previewItems[arrayIndex] return paginationState.backward == .timelineEndReached ? TimelineMediaPreviewItem.Loading.timelineStart : .paginating
} else if index > lastPreviewItemIndex {
return paginationState.forward == .timelineEndReached ? TimelineMediaPreviewItem.Loading.timelineEnd : .paginating
} else { } else {
return TimelineMediaPreviewLoadingItem.shared return previewItems[arrayIndex]
} }
} }
} }
// MARK: - TimelineMediaPreviewItem // MARK: - TimelineMediaPreviewItem
/// Wraps a media file and title to be previewed with QuickLook. enum TimelineMediaPreviewItem: Equatable {
class TimelineMediaPreviewItem: NSObject, QLPreviewItem, Identifiable { case media(Media)
case loading(Loading)
/// Wraps a media file and title to be previewed with QuickLook.
class Media: NSObject, QLPreviewItem, Identifiable {
fileprivate(set) var timelineItem: EventBasedMessageTimelineItemProtocol fileprivate(set) var timelineItem: EventBasedMessageTimelineItemProtocol
var fileHandle: MediaFileHandleProxy? var fileHandle: MediaFileHandleProxy?
var downloadError: Error? var downloadError: Error?
@ -254,11 +270,21 @@ class TimelineMediaPreviewItem: NSObject, QLPreviewItem, Identifiable {
nil nil
} }
} }
} }
class TimelineMediaPreviewLoadingItem: NSObject, QLPreviewItem { class Loading: NSObject, QLPreviewItem {
static let shared = TimelineMediaPreviewLoadingItem() static let paginating = Loading(state: .paginating)
static let timelineStart = Loading(state: .timelineStart)
static let timelineEnd = Loading(state: .timelineEnd)
enum State { case paginating, timelineStart, timelineEnd }
let state: State
let previewItemURL: URL? = nil let previewItemURL: URL? = nil
let previewItemTitle: String? = "" // Empty to force QLPreviewController to not show any text. let previewItemTitle: String? = "" // Empty to force QLPreviewController to not show any text.
init(state: State) {
self.state = state
}
}
} }

View File

@ -18,7 +18,7 @@ struct TimelineMediaPreviewViewState: BindableState {
var dataSource: TimelineMediaPreviewDataSource var dataSource: TimelineMediaPreviewDataSource
/// The media item that is currently being previewed. /// The media item that is currently being previewed.
var currentItem: TimelineMediaPreviewItem? { dataSource.currentItem } var currentItem: TimelineMediaPreviewItem { dataSource.currentItem }
/// All of the available actions for the current item. /// All of the available actions for the current item.
var currentItemActions: TimelineItemMenuActions? var currentItemActions: TimelineItemMenuActions?
@ -32,9 +32,9 @@ struct TimelineMediaPreviewViewState: BindableState {
struct TimelineMediaPreviewViewStateBindings { struct TimelineMediaPreviewViewStateBindings {
/// A binding that will present the Details view for the specified item. /// A binding that will present the Details view for the specified item.
var mediaDetailsItem: TimelineMediaPreviewItem? var mediaDetailsItem: TimelineMediaPreviewItem.Media?
/// A binding that will present a confirmation to redact the specified item. /// A binding that will present a confirmation to redact the specified item.
var redactConfirmationItem: TimelineMediaPreviewItem? var redactConfirmationItem: TimelineMediaPreviewItem.Media?
/// A binding that will present a document picker to export the specified file. /// A binding that will present a document picker to export the specified file.
var fileToExport: TimelineMediaPreviewFileExportPicker.File? var fileToExport: TimelineMediaPreviewFileExportPicker.File?
@ -46,9 +46,10 @@ enum TimelineMediaPreviewAlertType {
} }
enum TimelineMediaPreviewViewAction { enum TimelineMediaPreviewViewAction {
case updateCurrentItem(TimelineMediaPreviewItem?) case updateCurrentItem(TimelineMediaPreviewItem)
case showCurrentItemDetails case showItemDetails(TimelineMediaPreviewItem.Media)
case menuAction(TimelineItemMenuAction, item: TimelineMediaPreviewItem) case menuAction(TimelineItemMenuAction, item: TimelineMediaPreviewItem.Media)
case redactConfirmation(item: TimelineMediaPreviewItem) case redactConfirmation(item: TimelineMediaPreviewItem.Media)
case timelineEndReached
case dismiss case dismiss
} }

View File

@ -35,8 +35,11 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
self.userIndicatorController = userIndicatorController self.userIndicatorController = userIndicatorController
self.appMediator = appMediator self.appMediator = appMediator
super.init(initialViewState: TimelineMediaPreviewViewState(dataSource: .init(itemViewStates: timelineViewModel.context.viewState.timelineState.itemViewStates, let timelineState = timelineViewModel.context.viewState.timelineState
initialItem: context.item),
super.init(initialViewState: TimelineMediaPreviewViewState(dataSource: .init(itemViewStates: timelineState.itemViewStates,
initialItem: context.item,
paginationState: timelineState.paginationState),
transitionNamespace: context.namespace), transitionNamespace: context.namespace),
mediaProvider: mediaProvider) mediaProvider: mediaProvider)
@ -57,14 +60,19 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
self?.state.dataSource.updatePreviewItems(itemViewStates: itemViewStates) self?.state.dataSource.updatePreviewItems(itemViewStates: itemViewStates)
} }
.store(in: &cancellables) .store(in: &cancellables)
timelineViewModel.context.$viewState.map(\.timelineState.paginationState)
.removeDuplicates()
.weakAssign(to: \.state.dataSource.paginationState, on: self)
.store(in: &cancellables)
} }
override func process(viewAction: TimelineMediaPreviewViewAction) { override func process(viewAction: TimelineMediaPreviewViewAction) {
switch viewAction { switch viewAction {
case .updateCurrentItem(let item): case .updateCurrentItem(let item):
Task { await updateCurrentItem(item) } Task { await updateCurrentItem(item) }
case .showCurrentItemDetails: case .showItemDetails(let mediaItem):
state.bindings.mediaDetailsItem = state.currentItem state.bindings.mediaDetailsItem = mediaItem
case .menuAction(let action, let item): case .menuAction(let action, let item):
switch action { switch action {
case .viewInRoomTimeline: case .viewInRoomTimeline:
@ -78,28 +86,32 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
} }
case .redactConfirmation(let item): case .redactConfirmation(let item):
redactItem(item) redactItem(item)
case .timelineEndReached:
showTimelineEndIndicator()
case .dismiss: case .dismiss:
actionsSubject.send(.dismiss) actionsSubject.send(.dismiss)
} }
} }
private func updateCurrentItem(_ previewItem: TimelineMediaPreviewItem?) async { private func updateCurrentItem(_ previewItem: TimelineMediaPreviewItem) async {
previewItem?.downloadError = nil // Clear any existing error. if case let .media(item) = previewItem {
item.downloadError = nil // Clear any existing error.
}
state.dataSource.updateCurrentItem(previewItem) state.dataSource.updateCurrentItem(previewItem)
rebuildCurrentItemActions() rebuildCurrentItemActions()
if let previewItem { if case let .media(mediaItem) = previewItem {
currentItemIDHandler?(previewItem.id) currentItemIDHandler?(mediaItem.id)
if previewItem.fileHandle == nil, let source = previewItem.mediaSource { if mediaItem.fileHandle == nil, let source = mediaItem.mediaSource {
switch await mediaProvider.loadFileFromSource(source, filename: previewItem.filename) { switch await mediaProvider.loadFileFromSource(source, filename: mediaItem.filename) {
case .success(let handle): case .success(let handle):
previewItem.fileHandle = handle mediaItem.fileHandle = handle
state.fileLoadedPublisher.send(previewItem.id) state.fileLoadedPublisher.send(mediaItem.id)
case .failure(let error): case .failure(let error):
MXLog.error("Failed loading media: \(error)") MXLog.error("Failed loading media: \(error)")
context.objectWillChange.send() // Manually trigger the SwiftUI view update. context.objectWillChange.send() // Manually trigger the SwiftUI view update.
previewItem.downloadError = error mediaItem.downloadError = error
} }
} }
} }
@ -107,8 +119,9 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
private func rebuildCurrentItemActions() { private func rebuildCurrentItemActions() {
let timelineContext = timelineViewModel.context let timelineContext = timelineViewModel.context
state.currentItemActions = if let currentItem = state.currentItem { state.currentItemActions = switch state.currentItem {
TimelineItemMenuActionProvider(timelineItem: currentItem.timelineItem, case .media(let mediaItem):
TimelineItemMenuActionProvider(timelineItem: mediaItem.timelineItem,
canCurrentUserRedactSelf: timelineContext.viewState.canCurrentUserRedactSelf, canCurrentUserRedactSelf: timelineContext.viewState.canCurrentUserRedactSelf,
canCurrentUserRedactOthers: timelineContext.viewState.canCurrentUserRedactOthers, canCurrentUserRedactOthers: timelineContext.viewState.canCurrentUserRedactOthers,
canCurrentUserPin: timelineContext.viewState.canCurrentUserPin, canCurrentUserPin: timelineContext.viewState.canCurrentUserPin,
@ -118,13 +131,13 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
timelineKind: timelineContext.viewState.timelineKind, timelineKind: timelineContext.viewState.timelineKind,
emojiProvider: timelineContext.viewState.emojiProvider) emojiProvider: timelineContext.viewState.emojiProvider)
.makeActions() .makeActions()
} else { case .loading:
nil nil
} }
} }
private func saveCurrentItem() async { private func saveCurrentItem() async {
guard let currentItem = state.currentItem, let fileURL = currentItem.fileHandle?.url else { guard case let .media(mediaItem) = state.currentItem, let fileURL = mediaItem.fileHandle?.url else {
MXLog.error("Unable to save an item without a URL, the button shouldn't be visible.") MXLog.error("Unable to save an item without a URL, the button shouldn't be visible.")
return return
} }
@ -133,7 +146,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
state.bindings.mediaDetailsItem = nil state.bindings.mediaDetailsItem = nil
do { do {
switch currentItem.timelineItem { switch mediaItem.timelineItem {
case is AudioRoomTimelineItem, is FileRoomTimelineItem: case is AudioRoomTimelineItem, is FileRoomTimelineItem:
state.bindings.fileToExport = .init(url: fileURL) state.bindings.fileToExport = .init(url: fileURL)
return // Don't show the indicator. return // Don't show the indicator.
@ -158,7 +171,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
} }
} }
private func redactItem(_ item: TimelineMediaPreviewItem) { private func redactItem(_ item: TimelineMediaPreviewItem.Media) {
timelineViewModel.context.send(viewAction: .handleTimelineItemMenuAction(itemID: item.id, action: .redact)) timelineViewModel.context.send(viewAction: .handleTimelineItemMenuAction(itemID: item.id, action: .redact))
state.bindings.redactConfirmationItem = nil state.bindings.redactConfirmationItem = nil
state.bindings.mediaDetailsItem = nil state.bindings.mediaDetailsItem = nil
@ -189,5 +202,11 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
iconName: "xmark")) iconName: "xmark"))
} }
private func showTimelineEndIndicator() {
userIndicatorController.submitIndicator(UserIndicator(id: statusIndicatorID,
type: .toast,
title: L10n.screenMediaDetailsNoMoreMediaToShow))
}
private var statusIndicatorID: String { "\(Self.self)-Status" } private var statusIndicatorID: String { "\(Self.self)-Status" }
} }

View File

@ -9,7 +9,7 @@ import Compound
import SwiftUI import SwiftUI
struct TimelineMediaPreviewDetailsView: View { struct TimelineMediaPreviewDetailsView: View {
let item: TimelineMediaPreviewItem let item: TimelineMediaPreviewItem.Media
@ObservedObject var context: TimelineMediaPreviewViewModel.Context @ObservedObject var context: TimelineMediaPreviewViewModel.Context
@State private var sheetHeight: CGFloat = .zero @State private var sheetHeight: CGFloat = .zero
@ -132,7 +132,7 @@ struct TimelineMediaPreviewDetailsView: View {
} }
private struct ActionButton: View { private struct ActionButton: View {
let item: TimelineMediaPreviewItem let item: TimelineMediaPreviewItem.Media
let action: TimelineItemMenuAction let action: TimelineItemMenuAction
let context: TimelineMediaPreviewViewModel.Context let context: TimelineMediaPreviewViewModel.Context
@ -177,29 +177,31 @@ struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePrevie
static let presentedOnRoomViewModel = makeViewModel(isPresentedOnRoomScreen: true) static let presentedOnRoomViewModel = makeViewModel(isPresentedOnRoomScreen: true)
static var previews: some View { static var previews: some View {
// swiftlint:disable force_unwrapping if case let .media(mediaItem) = viewModel.state.currentItem {
TimelineMediaPreviewDetailsView(item: viewModel.state.currentItem!, TimelineMediaPreviewDetailsView(item: mediaItem, context: viewModel.context)
context: viewModel.context)
.previewDisplayName("Image") .previewDisplayName("Image")
.snapshotPreferences(expect: viewModel.context.$viewState.map { state in .snapshotPreferences(expect: viewModel.context.$viewState.map { state in
state.currentItemActions?.secondaryActions.contains(.redact) ?? false state.currentItemActions?.secondaryActions.contains(.redact) ?? false
}) })
}
TimelineMediaPreviewDetailsView(item: loadingViewModel.state.currentItem!, if case let .media(mediaItem) = loadingViewModel.state.currentItem {
context: loadingViewModel.context) TimelineMediaPreviewDetailsView(item: mediaItem, context: loadingViewModel.context)
.previewDisplayName("Loading") .previewDisplayName("Loading")
.snapshotPreferences(expect: loadingViewModel.context.$viewState.map { state in .snapshotPreferences(expect: loadingViewModel.context.$viewState.map { state in
state.currentItemActions?.secondaryActions.contains(.redact) ?? false state.currentItemActions?.secondaryActions.contains(.redact) ?? false
}) })
}
TimelineMediaPreviewDetailsView(item: unknownTypeViewModel.state.currentItem!, if case let .media(mediaItem) = unknownTypeViewModel.state.currentItem {
context: unknownTypeViewModel.context) TimelineMediaPreviewDetailsView(item: mediaItem, context: unknownTypeViewModel.context)
.previewDisplayName("Unknown type") .previewDisplayName("Unknown type")
}
TimelineMediaPreviewDetailsView(item: presentedOnRoomViewModel.state.currentItem!, if case let .media(mediaItem) = presentedOnRoomViewModel.state.currentItem {
context: presentedOnRoomViewModel.context) TimelineMediaPreviewDetailsView(item: mediaItem, context: presentedOnRoomViewModel.context)
.previewDisplayName("Incoming on Room") .previewDisplayName("Incoming on Room")
// swiftlint:enable force_unwrapping }
} }
static func makeViewModel(contentType: UTType? = nil, static func makeViewModel(contentType: UTType? = nil,

View File

@ -11,7 +11,7 @@ import SwiftUI
struct TimelineMediaPreviewRedactConfirmationView: View { struct TimelineMediaPreviewRedactConfirmationView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
let item: TimelineMediaPreviewItem let item: TimelineMediaPreviewItem.Media
@ObservedObject var context: TimelineMediaPreviewViewModel.Context @ObservedObject var context: TimelineMediaPreviewViewModel.Context
@State private var sheetHeight: CGFloat = .zero @State private var sheetHeight: CGFloat = .zero
@ -125,8 +125,9 @@ struct TimelineMediaPreviewRedactConfirmationView_Previews: PreviewProvider, Tes
static let viewModel = makeViewModel(contentType: .jpeg) static let viewModel = makeViewModel(contentType: .jpeg)
static var previews: some View { static var previews: some View {
// swiftlint:disable:next force_unwrapping if case let .media(mediaItem) = viewModel.state.currentItem {
TimelineMediaPreviewRedactConfirmationView(item: viewModel.state.currentItem!, context: viewModel.context) TimelineMediaPreviewRedactConfirmationView(item: mediaItem, context: viewModel.context)
}
} }
static func makeViewModel(contentType: UTType? = nil) -> TimelineMediaPreviewViewModel { static func makeViewModel(contentType: UTType? = nil) -> TimelineMediaPreviewViewModel {

View File

@ -17,7 +17,18 @@ struct TimelineMediaPreviewScreen: View {
@State private var isFullScreen = false @State private var isFullScreen = false
private var toolbarVisibility: Visibility { isFullScreen ? .hidden : .visible } private var toolbarVisibility: Visibility { isFullScreen ? .hidden : .visible }
private var currentItem: TimelineMediaPreviewItem? { context.viewState.currentItem } private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem }
private var currentItemID: TimelineItemIdentifier? {
guard case .media(let mediaItem) = currentItem else { return nil }
return mediaItem.id
}
private var shouldShowDownloadIndicator: Bool {
switch currentItem {
case .media(let mediaItem): mediaItem.fileHandle == nil
case .loading(let loadingItem): loadingItem.state == .paginating
}
}
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@ -40,7 +51,7 @@ struct TimelineMediaPreviewScreen: View {
.onDisappear { .onDisappear {
itemIDHandler?(nil) itemIDHandler?(nil)
} }
.zoomTransition(sourceID: currentItem?.id, in: context.viewState.transitionNamespace) .zoomTransition(sourceID: currentItemID, in: context.viewState.transitionNamespace)
} }
var quickLookPreview: some View { var quickLookPreview: some View {
@ -57,7 +68,7 @@ struct TimelineMediaPreviewScreen: View {
@ViewBuilder @ViewBuilder
private var fullScreenButton: some View { private var fullScreenButton: some View {
if currentItem != nil { if case .media = currentItem {
Button { Button {
withAnimation { isFullScreen.toggle() } withAnimation { isFullScreen.toggle() }
} label: { } label: {
@ -73,7 +84,7 @@ struct TimelineMediaPreviewScreen: View {
@ViewBuilder @ViewBuilder
private var downloadStatusIndicator: some View { private var downloadStatusIndicator: some View {
if currentItem?.downloadError != nil { if case let .media(mediaItem) = currentItem, mediaItem.downloadError != nil {
VStack(spacing: 24) { VStack(spacing: 24) {
CompoundIcon(\.error, size: .custom(48), relativeTo: .compound.headingLG) CompoundIcon(\.error, size: .custom(48), relativeTo: .compound.headingLG)
.foregroundStyle(.compound.iconCriticalPrimary) .foregroundStyle(.compound.iconCriticalPrimary)
@ -94,7 +105,7 @@ struct TimelineMediaPreviewScreen: View {
.padding(.horizontal, 24) .padding(.horizontal, 24)
.padding(.vertical, 40) .padding(.vertical, 40)
.background(.compound.bgSubtlePrimary, in: RoundedRectangle(cornerRadius: 14)) .background(.compound.bgSubtlePrimary, in: RoundedRectangle(cornerRadius: 14))
} else if currentItem?.fileHandle == nil { } else if shouldShowDownloadIndicator {
ProgressView() ProgressView()
.controlSize(.large) .controlSize(.large)
.tint(.compound.iconPrimary) .tint(.compound.iconPrimary)
@ -103,7 +114,7 @@ struct TimelineMediaPreviewScreen: View {
@ViewBuilder @ViewBuilder
private var caption: some View { private var caption: some View {
if let caption = currentItem?.caption, !isFullScreen { if case let .media(mediaItem) = currentItem, let caption = mediaItem.caption, !isFullScreen {
Text(caption) Text(caption)
.font(.compound.bodyLG) .font(.compound.bodyLG)
.foregroundStyle(.compound.textPrimary) .foregroundStyle(.compound.textPrimary)
@ -133,9 +144,9 @@ struct TimelineMediaPreviewScreen: View {
toolbarHeader toolbarHeader
} }
if currentItem != nil { if case let .media(mediaItem) = currentItem {
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
Button { context.send(viewAction: .showCurrentItemDetails) } label: { Button { context.send(viewAction: .showItemDetails(mediaItem)) } label: {
CompoundIcon(\.info) CompoundIcon(\.info)
} }
.tint(.compound.textActionPrimary) .tint(.compound.textActionPrimary)
@ -145,17 +156,18 @@ struct TimelineMediaPreviewScreen: View {
@ViewBuilder @ViewBuilder
private var toolbarHeader: some View { private var toolbarHeader: some View {
if let currentItem { switch currentItem {
case .media(let mediaItem):
VStack(spacing: 0) { VStack(spacing: 0) {
Text(currentItem.sender.displayName ?? currentItem.sender.id) Text(mediaItem.sender.displayName ?? mediaItem.sender.id)
.font(.compound.bodySMSemibold) .font(.compound.bodySMSemibold)
.foregroundStyle(.compound.textPrimary) .foregroundStyle(.compound.textPrimary)
Text(currentItem.timestamp.formatted(date: .abbreviated, time: .omitted)) Text(mediaItem.timestamp.formatted(date: .abbreviated, time: .omitted))
.font(.compound.bodyXS) .font(.compound.bodyXS)
.foregroundStyle(.compound.textPrimary) .foregroundStyle(.compound.textPrimary)
.textCase(.uppercase) .textCase(.uppercase)
} }
} else { case .loading:
Text(L10n.commonLoadingMore) Text(L10n.commonLoadingMore)
.font(.compound.bodySMSemibold) .font(.compound.bodySMSemibold)
.foregroundStyle(.compound.textPrimary) .foregroundStyle(.compound.textPrimary)
@ -215,20 +227,41 @@ private struct QuickLookView: UIViewControllerRepresentable {
} }
private func loadCurrentItem() { private func loadCurrentItem() {
viewModelContext.send(viewAction: .updateCurrentItem(previewController.currentPreviewItem as? TimelineMediaPreviewItem)) if let previewItem = previewController.currentPreviewItem as? TimelineMediaPreviewItem.Media {
viewModelContext.send(viewAction: .updateCurrentItem(.media(previewItem)))
} else if let loadingItem = previewController.currentPreviewItem as? TimelineMediaPreviewItem.Loading {
switch loadingItem.state {
case .paginating:
viewModelContext.send(viewAction: .updateCurrentItem(.loading(loadingItem)))
case .timelineStart:
Task { await returnToIndex(viewModelContext.viewState.dataSource.firstPreviewItemIndex) }
case .timelineEnd:
Task { await returnToIndex(viewModelContext.viewState.dataSource.lastPreviewItemIndex) }
}
} else {
MXLog.error("Unexpected preview item type: \(type(of: previewController.currentPreviewItem))")
}
}
private func returnToIndex(_ index: Int) async {
// Sleep to fix a bug where the update didn't take effect when the swipe velocity was slow.
try? await Task.sleep(for: .seconds(0.1))
previewController.currentPreviewItemIndex = index
viewModelContext.send(viewAction: .timelineEndReached)
} }
private func handleUpdatedItems() { private func handleUpdatedItems() {
if previewController.currentPreviewItem is TimelineMediaPreviewLoadingItem { if previewController.currentPreviewItem is TimelineMediaPreviewItem.Loading {
let dataSource = viewModelContext.viewState.dataSource let dataSource = viewModelContext.viewState.dataSource
if dataSource.previewController(previewController, previewItemAt: previewController.currentPreviewItemIndex) is TimelineMediaPreviewItem { if dataSource.previewController(previewController, previewItemAt: previewController.currentPreviewItemIndex) is TimelineMediaPreviewItem.Media {
previewController.refreshCurrentPreviewItem() // This will trigger loadCurrentItem automatically. previewController.refreshCurrentPreviewItem() // This will trigger loadCurrentItem automatically.
} }
} }
} }
private func handleFileLoaded(itemID: TimelineItemIdentifier) { private func handleFileLoaded(itemID: TimelineItemIdentifier) {
guard (previewController.currentPreviewItem as? TimelineMediaPreviewItem)?.id == itemID else { return } guard (previewController.currentPreviewItem as? TimelineMediaPreviewItem.Media)?.id == itemID else { return }
previewController.refreshCurrentPreviewItem() previewController.refreshCurrentPreviewItem()
} }
} }

View File

@ -586,10 +586,13 @@ final class TimelineProxy: TimelineProxyProtocol {
MXLog.error("Failed to subscribe to back pagination status with error: \(error)") MXLog.error("Failed to subscribe to back pagination status with error: \(error)")
} }
forwardPaginationStatusSubject.send(.timelineEndReached) forwardPaginationStatusSubject.send(.timelineEndReached)
case .detached, .media: case .detached:
// Detached timelines don't support observation, set the initial state ourself. // Detached timelines don't support observation, set the initial state ourself.
backPaginationStatusSubject.send(.idle) backPaginationStatusSubject.send(.idle)
forwardPaginationStatusSubject.send(.idle) forwardPaginationStatusSubject.send(.idle)
case .media(let presentation):
backPaginationStatusSubject.send(.idle)
forwardPaginationStatusSubject.send(presentation == .mediaFilesScreen ? .timelineEndReached : .idle)
case .pinned: case .pinned:
backPaginationStatusSubject.send(.timelineEndReached) backPaginationStatusSubject.send(.timelineEndReached)
forwardPaginationStatusSubject.send(.timelineEndReached) forwardPaginationStatusSubject.send(.timelineEndReached)

View File

@ -27,16 +27,17 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase {
// Given a data source built with the initial items. // Given a data source built with the initial items.
let dataSource = TimelineMediaPreviewDataSource(itemViewStates: initialMediaViewStates, let dataSource = TimelineMediaPreviewDataSource(itemViewStates: initialMediaViewStates,
initialItem: initialMediaItems[initialItemIndex], initialItem: initialMediaItems[initialItemIndex],
initialPadding: initialPadding) initialPadding: initialPadding,
paginationState: .initial)
// When the preview controller displays the data. // When the preview controller displays the data.
let previewItemCount = dataSource.numberOfPreviewItems(in: previewController) let previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
let displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem let displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media
// Then the preview controller should be showing the initial item and the data source should reflect this. // 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(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(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.currentMediaItemID, 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(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.") XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The initial item count should be padded for the preview controller.")
@ -46,23 +47,29 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase {
func testCurrentUpdateItem() { func testCurrentUpdateItem() {
// Given a data source built with the initial items. // Given a data source built with the initial items.
let dataSource = TimelineMediaPreviewDataSource(itemViewStates: initialMediaViewStates, initialItem: initialMediaItems[initialItemIndex]) let dataSource = TimelineMediaPreviewDataSource(itemViewStates: initialMediaViewStates,
initialItem: initialMediaItems[initialItemIndex],
paginationState: .initial)
// When a different item is displayed. // When a different item is displayed.
let previewItem = dataSource.previewController(previewController, previewItemAt: 1 + initialPadding) as? TimelineMediaPreviewItem guard let previewItem = dataSource.previewController(previewController, previewItemAt: 1 + initialPadding) as? TimelineMediaPreviewItem.Media else {
XCTAssertNotNil(previewItem, "A preview item should be found.") XCTFail("A preview item should be found.")
dataSource.updateCurrentItem(previewItem) return
}
dataSource.updateCurrentItem(.media(previewItem))
// Then the data source should reflect the change of item. // Then the data source should reflect the change of item.
XCTAssertEqual(dataSource.currentItem?.id, previewItem?.id, "The displayed item should be the initial item.") XCTAssertEqual(dataSource.currentMediaItemID, previewItem.id, "The displayed item should be the initial item.")
// When a loading item is displayed. // When a loading item is displayed.
let loadingItem = dataSource.previewController(previewController, previewItemAt: initialPadding - 1) as? TimelineMediaPreviewLoadingItem guard let loadingItem = dataSource.previewController(previewController, previewItemAt: initialPadding - 1) as? TimelineMediaPreviewItem.Loading else {
XCTAssertNotNil(loadingItem, "A loading item should be be returned.") XCTFail("A loading item should be be returned.")
dataSource.updateCurrentItem(nil) return
}
dataSource.updateCurrentItem(.loading(loadingItem))
// Then the data source should indicate that no item is being displayed. // Then the data source should show a loading item
XCTAssertNil(dataSource.currentItem, "The current item should be nil.") XCTAssertEqual(dataSource.currentItem, .loading(loadingItem), "The displayed item should be the loading item.")
} }
func testUpdatedItems() async throws { func testUpdatedItems() async throws {
@ -77,9 +84,9 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase {
try await deferred.fulfill() try await deferred.fulfill()
let previewItemCount = dataSource.numberOfPreviewItems(in: previewController) let previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
let displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem let displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media
XCTAssertEqual(displayedItem?.id, initialMediaItems[initialItemIndex].id, "The displayed item should not change.") 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.currentMediaItemID, initialMediaItems[initialItemIndex].id, "The current item should not change.")
XCTAssertEqual(dataSource.previewItems.count, initialMediaViewStates.count, "The number of items 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.") XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The padded number of items should not change.")
@ -100,9 +107,9 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase {
XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.") XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.")
var previewItemCount = dataSource.numberOfPreviewItems(in: previewController) var previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
var displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem var displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media
XCTAssertEqual(displayedItem?.id, initialMediaItems[initialItemIndex].id, "The displayed item should not change.") 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.currentMediaItemID, initialMediaItems[initialItemIndex].id, "The current item should not change.")
XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items 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. // When more items are loaded in a forward pagination or sync.
@ -116,9 +123,9 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase {
XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.") XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.")
previewItemCount = dataSource.numberOfPreviewItems(in: previewController) previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media
XCTAssertEqual(displayedItem?.id, initialMediaItems[initialItemIndex].id, "The displayed item should not change.") 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.currentMediaItemID, initialMediaItems[initialItemIndex].id, "The current item should not change.")
XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change") XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change")
} }
@ -139,9 +146,9 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase {
XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.") XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.")
var previewItemCount = dataSource.numberOfPreviewItems(in: previewController) var previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
var displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem var displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media
XCTAssertEqual(displayedItem?.id, initialMediaItems[initialItemIndex].id, "The displayed item should not change.") 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.currentMediaItemID, initialMediaItems[initialItemIndex].id, "The current item should not change.")
XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items 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. // When paginating forwards by more than the available padding.
@ -155,9 +162,9 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase {
XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.") XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.")
previewItemCount = dataSource.numberOfPreviewItems(in: previewController) previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media
XCTAssertEqual(displayedItem?.id, initialMediaItems[initialItemIndex].id, "The displayed item should not change.") 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.currentMediaItemID, initialMediaItems[initialItemIndex].id, "The current item should not change.")
XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change") XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change")
} }
@ -169,3 +176,12 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase {
.filter(\.supportsMediaCaption) // Voice messages can't be previewed (and don't support captions). .filter(\.supportsMediaCaption) // Voice messages can't be previewed (and don't support captions).
} }
} }
private extension TimelineMediaPreviewDataSource {
var currentMediaItemID: TimelineItemIdentifier? {
switch currentItem {
case .media(let mediaItem): mediaItem.id
case .loading: nil
}
}
}

View File

@ -25,7 +25,7 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
// Given a fresh view model. // Given a fresh view model.
setupViewModel() setupViewModel()
XCTAssertFalse(mediaProvider.loadFileFromSourceFilenameCalled) XCTAssertFalse(mediaProvider.loadFileFromSourceFilenameCalled)
XCTAssertEqual(context.viewState.currentItem, context.viewState.dataSource.previewItems[0]) XCTAssertEqual(context.viewState.currentItem, .media(context.viewState.dataSource.previewItems[0]))
XCTAssertNotNil(context.viewState.currentItemActions) XCTAssertNotNil(context.viewState.currentItemActions)
// When the preview controller sets the current item. // When the preview controller sets the current item.
@ -33,32 +33,32 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
// Then the view model should load the item and update its view state. // Then the view model should load the item and update its view state.
XCTAssertTrue(mediaProvider.loadFileFromSourceFilenameCalled) XCTAssertTrue(mediaProvider.loadFileFromSourceFilenameCalled)
XCTAssertEqual(context.viewState.currentItem, context.viewState.dataSource.previewItems[0]) XCTAssertEqual(context.viewState.currentItem, .media(context.viewState.dataSource.previewItems[0]))
XCTAssertNotNil(context.viewState.currentItemActions) XCTAssertNotNil(context.viewState.currentItemActions)
} }
func testLoadingItemFailure() async throws { func testLoadingItemFailure() async throws {
// Given a fresh view model. // Given a fresh view model.
setupViewModel() setupViewModel()
guard let currentItem = context.viewState.currentItem else { guard case let .media(mediaItem) = context.viewState.currentItem else {
XCTFail("There should be a current item") XCTFail("There should be a current item")
return return
} }
XCTAssertFalse(mediaProvider.loadFileFromSourceFilenameCalled) XCTAssertFalse(mediaProvider.loadFileFromSourceFilenameCalled)
XCTAssertEqual(currentItem, context.viewState.dataSource.previewItems[0]) XCTAssertEqual(mediaItem, context.viewState.dataSource.previewItems[0])
XCTAssertNil(currentItem.downloadError) XCTAssertNil(mediaItem.downloadError)
// When the preview controller sets an item that fails to load. // When the preview controller sets an item that fails to load.
mediaProvider.loadFileFromSourceFilenameClosure = { _, _ in .failure(.failedRetrievingFile) } mediaProvider.loadFileFromSourceFilenameClosure = { _, _ in .failure(.failedRetrievingFile) }
let failure = deferFailure(viewModel.state.fileLoadedPublisher, timeout: 1) { _ in true } let failure = deferFailure(viewModel.state.fileLoadedPublisher, timeout: 1) { _ in true }
context.send(viewAction: .updateCurrentItem(context.viewState.dataSource.previewItems[0])) context.send(viewAction: .updateCurrentItem(.media(context.viewState.dataSource.previewItems[0])))
try await failure.fulfill() try await failure.fulfill()
// Then the view model should load the item and update its view state. // Then the view model should load the item and update its view state.
XCTAssertTrue(mediaProvider.loadFileFromSourceFilenameCalled) XCTAssertTrue(mediaProvider.loadFileFromSourceFilenameCalled)
XCTAssertEqual(currentItem, context.viewState.dataSource.previewItems[0]) XCTAssertEqual(mediaItem, context.viewState.dataSource.previewItems[0])
XCTAssertNotNil(currentItem.downloadError) XCTAssertNotNil(mediaItem.downloadError)
} }
func testSwipingBetweenItems() async throws { func testSwipingBetweenItems() async throws {
@ -67,21 +67,21 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
// When swiping to another item. // When swiping to another item.
let deferred = deferFulfillment(viewModel.state.fileLoadedPublisher) { _ in true } let deferred = deferFulfillment(viewModel.state.fileLoadedPublisher) { _ in true }
context.send(viewAction: .updateCurrentItem(context.viewState.dataSource.previewItems[1])) context.send(viewAction: .updateCurrentItem(.media(context.viewState.dataSource.previewItems[1])))
try await deferred.fulfill() try await deferred.fulfill()
// Then the view model should load the item and update its view state. // Then the view model should load the item and update its view state.
XCTAssertEqual(mediaProvider.loadFileFromSourceFilenameCallsCount, 2) XCTAssertEqual(mediaProvider.loadFileFromSourceFilenameCallsCount, 2)
XCTAssertEqual(context.viewState.currentItem, context.viewState.dataSource.previewItems[1]) XCTAssertEqual(context.viewState.currentItem, .media(context.viewState.dataSource.previewItems[1]))
// When swiping back to the first item. // When swiping back to the first item.
let failure = deferFailure(viewModel.state.fileLoadedPublisher, timeout: 1) { _ in true } let failure = deferFailure(viewModel.state.fileLoadedPublisher, timeout: 1) { _ in true }
context.send(viewAction: .updateCurrentItem(context.viewState.dataSource.previewItems[0])) context.send(viewAction: .updateCurrentItem(.media(context.viewState.dataSource.previewItems[0])))
try await failure.fulfill() try await failure.fulfill()
// Then the view model should not need to load the item, but should still update its view state. // Then the view model should not need to load the item, but should still update its view state.
XCTAssertEqual(mediaProvider.loadFileFromSourceFilenameCallsCount, 2) XCTAssertEqual(mediaProvider.loadFileFromSourceFilenameCallsCount, 2)
XCTAssertEqual(context.viewState.currentItem, context.viewState.dataSource.previewItems[0]) XCTAssertEqual(context.viewState.currentItem, .media(context.viewState.dataSource.previewItems[0]))
} }
func testLoadingMoreItem() async throws { func testLoadingMoreItem() async throws {
@ -90,12 +90,12 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
// When swiping to a "loading more" item. // When swiping to a "loading more" item.
let deferred = deferFailure(viewModel.state.fileLoadedPublisher, timeout: 1) { _ in true } let deferred = deferFailure(viewModel.state.fileLoadedPublisher, timeout: 1) { _ in true }
context.send(viewAction: .updateCurrentItem(nil)) context.send(viewAction: .updateCurrentItem(.loading(.paginating)))
try await deferred.fulfill() try await deferred.fulfill()
// Then there should no longer be a media preview and no attempt should be made to load one. // Then there should no longer be a media preview and no attempt should be made to load one.
XCTAssertEqual(mediaProvider.loadFileFromSourceFilenameCallsCount, 1) XCTAssertEqual(mediaProvider.loadFileFromSourceFilenameCallsCount, 1)
XCTAssertNil(context.viewState.currentItem) XCTAssertEqual(context.viewState.currentItem, .loading(.paginating))
} }
func testPagination() async throws { func testPagination() async throws {
@ -112,7 +112,7 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
// 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). // 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) } mediaProvider.loadFileFromSourceFilenameClosure = { _, _ in .failure(.failedRetrievingFile) }
let failure = deferFailure(viewModel.state.fileLoadedPublisher, timeout: 1) { _ in true } let failure = deferFailure(viewModel.state.fileLoadedPublisher, timeout: 1) { _ in true }
context.send(viewAction: .updateCurrentItem(context.viewState.dataSource.previewItems[3])) context.send(viewAction: .updateCurrentItem(.media(context.viewState.dataSource.previewItems[3])))
try await failure.fulfill() try await failure.fulfill()
// Then the current item shouldn't need to be reloaded. // Then the current item shouldn't need to be reloaded.
@ -125,13 +125,13 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
try await testLoadingItem() try await testLoadingItem()
// When choosing to view the current item in the timeline. // When choosing to view the current item in the timeline.
guard let item = context.viewState.currentItem else { guard case let .media(mediaItem) = context.viewState.currentItem else {
XCTFail("There should be a current item.") XCTFail("There should be a current item.")
return return
} }
let deferred = deferFulfillment(viewModel.actions) { $0 == .viewInRoomTimeline(item.id) } let deferred = deferFulfillment(viewModel.actions) { $0 == .viewInRoomTimeline(mediaItem.id) }
context.send(viewAction: .menuAction(.viewInRoomTimeline, item: item)) context.send(viewAction: .menuAction(.viewInRoomTimeline, item: mediaItem))
// Then the action should be sent upwards to make this happen. // Then the action should be sent upwards to make this happen.
try await deferred.fulfill() try await deferred.fulfill()
@ -142,27 +142,31 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
try await testLoadingItem() try await testLoadingItem()
XCTAssertNil(context.redactConfirmationItem) XCTAssertNil(context.redactConfirmationItem)
XCTAssertFalse(timelineController.redactCalled) XCTAssertFalse(timelineController.redactCalled)
guard case let .media(mediaItem) = context.viewState.currentItem else {
XCTFail("There should be a current item.")
return
}
// When choosing to show the item details. // When choosing to show the item details.
context.send(viewAction: .showCurrentItemDetails) context.send(viewAction: .showItemDetails(mediaItem))
// Then the details sheet should be presented. // Then the details sheet should be presented.
guard let item = context.mediaDetailsItem else { guard let mediaDetailsItem = context.mediaDetailsItem else {
XCTFail("The default of the current item should be presented") XCTFail("The default of the current item should be presented")
return return
} }
XCTAssertEqual(context.mediaDetailsItem, context.viewState.currentItem) XCTAssertEqual(.media(mediaDetailsItem), context.viewState.currentItem)
// When choosing to redact the item. // When choosing to redact the item.
context.send(viewAction: .menuAction(.redact, item: item)) context.send(viewAction: .menuAction(.redact, item: mediaDetailsItem))
// Then the confirmation sheet should be presented. // Then the confirmation sheet should be presented.
XCTAssertEqual(context.redactConfirmationItem, item) XCTAssertEqual(context.redactConfirmationItem, mediaDetailsItem)
XCTAssertFalse(timelineController.redactCalled) XCTAssertFalse(timelineController.redactCalled)
// When confirming the redaction. // When confirming the redaction.
let deferred = deferFulfillment(viewModel.actions) { $0 == .dismiss } let deferred = deferFulfillment(viewModel.actions) { $0 == .dismiss }
context.send(viewAction: .redactConfirmation(item: item)) context.send(viewAction: .redactConfirmation(item: mediaDetailsItem))
// Then the item should be redacted and the view should be dismissed. // Then the item should be redacted and the view should be dismissed.
try await deferred.fulfill() try await deferred.fulfill()
@ -172,35 +176,35 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
func testSaveImage() async throws { func testSaveImage() async throws {
// Given a view model with a loaded image. // Given a view model with a loaded image.
try await testLoadingItem() try await testLoadingItem()
guard let currentItem = context.viewState.currentItem else { guard case let .media(mediaItem) = context.viewState.currentItem else {
XCTFail("There should be a current item") XCTFail("There should be a current item")
return return
} }
XCTAssertEqual(currentItem.contentType, "JPEG image") XCTAssertEqual(mediaItem.contentType, "JPEG image")
// When choosing to save the image. // When choosing to save the image.
context.send(viewAction: .menuAction(.save, item: currentItem)) context.send(viewAction: .menuAction(.save, item: mediaItem))
try await Task.sleep(for: .seconds(0.5)) try await Task.sleep(for: .seconds(0.5))
// Then the image should be saved as a photo to the user's photo library. // Then the image should be saved as a photo to the user's photo library.
XCTAssertTrue(photoLibraryManager.addResourceAtCalled) XCTAssertTrue(photoLibraryManager.addResourceAtCalled)
XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.type, .photo) XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.type, .photo)
XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.url, currentItem.fileHandle?.url) XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.url, mediaItem.fileHandle?.url)
} }
func testSaveImageWithoutAuthorization() async throws { func testSaveImageWithoutAuthorization() async throws {
// Given a view model with a loaded image where the user has denied access to the photo library. // Given a view model with a loaded image where the user has denied access to the photo library.
setupViewModel(photoLibraryAuthorizationDenied: true) setupViewModel(photoLibraryAuthorizationDenied: true)
try await loadInitialItem() try await loadInitialItem()
guard let currentItem = context.viewState.currentItem else { guard case let .media(mediaItem) = context.viewState.currentItem else {
XCTFail("There should be a current item") XCTFail("There should be a current item")
return return
} }
XCTAssertEqual(currentItem.contentType, "JPEG image") XCTAssertEqual(mediaItem.contentType, "JPEG image")
// When choosing to save the image. // When choosing to save the image.
let deferred = deferFulfillment(context.$viewState) { $0.bindings.alertInfo != nil } let deferred = deferFulfillment(context.$viewState) { $0.bindings.alertInfo != nil }
context.send(viewAction: .menuAction(.save, item: currentItem)) context.send(viewAction: .menuAction(.save, item: mediaItem))
try await deferred.fulfill() try await deferred.fulfill()
// Then the user should be prompted to allow access. // Then the user should be prompted to allow access.
@ -212,40 +216,40 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
// Given a view model with a loaded video. // Given a view model with a loaded video.
setupViewModel(initialItemIndex: 1) setupViewModel(initialItemIndex: 1)
try await loadInitialItem() try await loadInitialItem()
guard let currentItem = context.viewState.currentItem else { guard case let .media(mediaItem) = context.viewState.currentItem else {
XCTFail("There should be a current item") XCTFail("There should be a current item")
return return
} }
XCTAssertEqual(currentItem.contentType, "MPEG-4 movie") XCTAssertEqual(mediaItem.contentType, "MPEG-4 movie")
// When choosing to save the video. // When choosing to save the video.
context.send(viewAction: .menuAction(.save, item: currentItem)) context.send(viewAction: .menuAction(.save, item: mediaItem))
try await Task.sleep(for: .seconds(0.5)) try await Task.sleep(for: .seconds(0.5))
// Then the video should be saved as a video in the user's photo library. // Then the video should be saved as a video in the user's photo library.
XCTAssertTrue(photoLibraryManager.addResourceAtCalled) XCTAssertTrue(photoLibraryManager.addResourceAtCalled)
XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.type, .video) XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.type, .video)
XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.url, currentItem.fileHandle?.url) XCTAssertEqual(photoLibraryManager.addResourceAtReceivedArguments?.url, mediaItem.fileHandle?.url)
} }
func testSaveFile() async throws { func testSaveFile() async throws {
// Given a view model with a loaded file. // Given a view model with a loaded file.
setupViewModel(initialItemIndex: 2) setupViewModel(initialItemIndex: 2)
try await loadInitialItem() try await loadInitialItem()
guard let currentItem = context.viewState.currentItem else { guard case let .media(mediaItem) = context.viewState.currentItem else {
XCTFail("There should be a current item") XCTFail("There should be a current item")
return return
} }
XCTAssertEqual(currentItem.contentType, "PDF document") XCTAssertEqual(mediaItem.contentType, "PDF document")
// When choosing to save the file. // When choosing to save the file.
context.send(viewAction: .menuAction(.save, item: currentItem)) context.send(viewAction: .menuAction(.save, item: mediaItem))
try await Task.sleep(for: .seconds(0.5)) 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. // Then the binding should be set for the user to export the file to their specified location.
XCTAssertFalse(photoLibraryManager.addResourceAtCalled) XCTAssertFalse(photoLibraryManager.addResourceAtCalled)
XCTAssertNotNil(context.fileToExport) XCTAssertNotNil(context.fileToExport)
XCTAssertEqual(context.fileToExport?.url, currentItem.fileHandle?.url) XCTAssertEqual(context.fileToExport?.url, mediaItem.fileHandle?.url)
} }
func testDismiss() async throws { func testDismiss() async throws {
@ -266,11 +270,11 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
let deferred = deferFulfillment(viewModel.state.fileLoadedPublisher) { _ in true } let deferred = deferFulfillment(viewModel.state.fileLoadedPublisher) { _ in true }
let initialItem = context.viewState.dataSource.previewController(QLPreviewController(), let initialItem = context.viewState.dataSource.previewController(QLPreviewController(),
previewItemAt: context.viewState.dataSource.initialItemIndex) previewItemAt: context.viewState.dataSource.initialItemIndex)
guard let initialPreviewItem = initialItem as? TimelineMediaPreviewItem else { guard let initialPreviewItem = initialItem as? TimelineMediaPreviewItem.Media else {
XCTFail("1234") XCTFail("The initial item should be a media preview.")
return return
} }
context.send(viewAction: .updateCurrentItem(initialPreviewItem)) context.send(viewAction: .updateCurrentItem(.media(initialPreviewItem)))
try await deferred.fulfill() try await deferred.fulfill()
} }