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,264 @@
//
// 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.
//
import Combine
import Foundation
import QuickLook
/// A dedicated data source for QLPreviewController to support timeline updates. This was added to
/// workaround the fact that calling `reloadData` on the controller **always** reloads the current
/// item (even if hasn't changed), so any interaction (zoom, media playback, scroll position) would be
/// lost.
///
/// This data source pads the initial array with 100 spaces before and after, adding any pagination into
/// this fixed space. This removes the need to reload the data and preserves the current item's index
/// in the data.
class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource {
/// All of the items in the timeline that can be previewed.
private(set) var previewItems: [TimelineMediaPreviewItem]
let previewItemsPaginationPublisher = PassthroughSubject<Void, Never>()
private let initialItem: EventBasedMessageTimelineItemProtocol
/// The index of the initial item inside of `previewItems` that is to be shown.
let initialItemIndex: Int
/// The media item that is currently being previewed.
private(set) var currentItem: TimelineMediaPreviewItem?
private var backwardPadding: Int
private var forwardPadding: Int
init(itemViewStates: [RoomTimelineItemViewState], initialItem: EventBasedMessageTimelineItemProtocol, initialPadding: Int = 100) {
previewItems = itemViewStates.compactMap(TimelineMediaPreviewItem.init)
self.initialItem = initialItem
let initialItemArrayIndex = previewItems.firstIndex { $0.id == initialItem.id } ?? 0
initialItemIndex = initialItemArrayIndex + initialPadding
currentItem = previewItems[initialItemArrayIndex]
backwardPadding = initialPadding
forwardPadding = initialPadding
}
func updateCurrentItem(_ item: TimelineMediaPreviewItem?) {
currentItem = item
}
func updatePreviewItems(itemViewStates: [RoomTimelineItemViewState]) {
let newItems: [TimelineMediaPreviewItem] = itemViewStates.compactMap { itemViewState in
guard let newItem = TimelineMediaPreviewItem(roomTimelineItemViewState: itemViewState) else { return nil }
// 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 }) {
oldItem.timelineItem = newItem.timelineItem
return oldItem
}
return newItem
}
var hasPaginated = false
if let range = newItems.map(\.id).firstRange(of: previewItems.map(\.id)) {
let backPaginationCount = range.lowerBound
let forwardPaginationCount = newItems.indices.upperBound - range.upperBound
// Don't worry about negative padding here. Turns out that it just limits
// the displayable items from growing any more, but makes sure that the
// current item doesn't jump around so we don't need to reload anything.
backwardPadding -= backPaginationCount
forwardPadding -= forwardPaginationCount
if backPaginationCount > 0 || forwardPaginationCount > 0 {
hasPaginated = true
}
} else {
// Do nothing! Not ideal but if we reload the data source the current item will
// also be, reloaded resetting any interaction the user has made with it. If we
// ignore the pagination, then the next time they swipe they'll land on a different
// media but this is probably less jarring overall. I hate QLPreviewController!
}
previewItems = newItems
if hasPaginated {
previewItemsPaginationPublisher.send()
}
}
// MARK: - QLPreviewControllerDataSource
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
previewItems.count + backwardPadding + forwardPadding
}
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> any QLPreviewItem {
let arrayIndex = index - backwardPadding
if arrayIndex >= 0, arrayIndex < previewItems.count {
return previewItems[arrayIndex]
} else {
return TimelineMediaPreviewLoadingItem.shared
}
}
}
// MARK: - TimelineMediaPreviewItem
/// Wraps a media file and title to be previewed with QuickLook.
class TimelineMediaPreviewItem: NSObject, QLPreviewItem, Identifiable {
fileprivate(set) var timelineItem: EventBasedMessageTimelineItemProtocol
var fileHandle: MediaFileHandleProxy?
var downloadError: Error?
init(timelineItem: EventBasedMessageTimelineItemProtocol) {
self.timelineItem = timelineItem
}
init?(roomTimelineItemViewState: RoomTimelineItemViewState) {
switch roomTimelineItemViewState.type {
case .audio(let audioRoomTimelineItem):
timelineItem = audioRoomTimelineItem
case .file(let fileRoomTimelineItem):
timelineItem = fileRoomTimelineItem
case .image(let imageRoomTimelineItem):
timelineItem = imageRoomTimelineItem
case .video(let videoRoomTimelineItem):
timelineItem = videoRoomTimelineItem
default:
return nil
}
}
// MARK: Identifiable
var id: TimelineItemIdentifier { timelineItem.id }
// MARK: QLPreviewItem
var previewItemURL: URL? {
// Falling back to a clear image allows the presentation animation to work when
// the item is in the event cache and just needs to be loaded from the store.
fileHandle?.url ?? Bundle.main.url(forResource: "clear", withExtension: "png")
}
var previewItemTitle: String? {
filename
}
// MARK: Event details
var sender: TimelineItemSender {
timelineItem.sender
}
var timestamp: Date {
timelineItem.timestamp
}
// MARK: Media details
var mediaSource: MediaSourceProxy? {
switch timelineItem {
case let audioItem as AudioRoomTimelineItem:
audioItem.content.source
case let fileItem as FileRoomTimelineItem:
fileItem.content.source
case let imageItem as ImageRoomTimelineItem:
imageItem.content.imageInfo.source
case let videoItem as VideoRoomTimelineItem:
videoItem.content.videoInfo.source
default:
nil
}
}
var thumbnailMediaSource: MediaSourceProxy? {
switch timelineItem {
case let fileItem as FileRoomTimelineItem:
fileItem.content.thumbnailSource
case let imageItem as ImageRoomTimelineItem:
imageItem.content.thumbnailInfo?.source
case let videoItem as VideoRoomTimelineItem:
videoItem.content.thumbnailInfo?.source
default:
nil
}
}
var filename: String? {
switch timelineItem {
case let audioItem as AudioRoomTimelineItem:
audioItem.content.filename
case let fileItem as FileRoomTimelineItem:
fileItem.content.filename
case let imageItem as ImageRoomTimelineItem:
imageItem.content.filename
case let videoItem as VideoRoomTimelineItem:
videoItem.content.filename
default:
nil
}
}
var fileSize: Double? {
previewItemURL.flatMap { try? FileManager.default.sizeForItem(at: $0) } ?? expectedFileSize
}
private var expectedFileSize: Double? {
let fileSize: UInt? = switch timelineItem {
case let audioItem as AudioRoomTimelineItem:
audioItem.content.fileSize
case let fileItem as FileRoomTimelineItem:
fileItem.content.fileSize
case let imageItem as ImageRoomTimelineItem:
imageItem.content.imageInfo.fileSize
case let videoItem as VideoRoomTimelineItem:
videoItem.content.videoInfo.fileSize
default:
nil
}
return fileSize.map(Double.init)
}
var caption: String? {
timelineItem.mediaCaption
}
var contentType: String? {
switch timelineItem {
case let audioItem as AudioRoomTimelineItem:
audioItem.content.contentType?.localizedDescription
case let fileItem as FileRoomTimelineItem:
fileItem.content.contentType?.localizedDescription
case let imageItem as ImageRoomTimelineItem:
imageItem.content.contentType?.localizedDescription
case let videoItem as VideoRoomTimelineItem:
videoItem.content.contentType?.localizedDescription
default:
nil
}
}
var blurhash: String? {
switch timelineItem {
case let imageItem as ImageRoomTimelineItem:
imageItem.content.blurhash
case let videoItem as VideoRoomTimelineItem:
videoItem.content.blurhash
default:
nil
}
}
}
class TimelineMediaPreviewLoadingItem: NSObject, QLPreviewItem {
static let shared = TimelineMediaPreviewLoadingItem()
let previewItemURL: URL? = nil
let previewItemTitle: String? = "" // Empty to force QLPreviewController to not show any text.
}
@@ -6,7 +6,6 @@
//
import Combine
import QuickLook
import SwiftUI
enum TimelineMediaPreviewViewModelAction: Equatable {
@@ -15,13 +14,11 @@ enum TimelineMediaPreviewViewModelAction: Equatable {
}
struct TimelineMediaPreviewViewState: BindableState {
/// All of the items in the timeline that can be previewed.
var previewItems: [TimelineMediaPreviewItem]
/// The index of the initial item inside of `previewItems` that is to be shown.
let initialItemIndex: Int
/// The data source for all of the preview-able items.
var dataSource: TimelineMediaPreviewDataSource
/// The media item that is currently being previewed.
var currentItem: TimelineMediaPreviewItem
var currentItem: TimelineMediaPreviewItem? { dataSource.currentItem }
/// All of the available actions for the current item.
var currentItemActions: TimelineItemMenuActions?
@@ -48,156 +45,8 @@ enum TimelineMediaPreviewAlertType {
case authorizationRequired
}
/// Wraps a media file and title to be previewed with QuickLook.
class TimelineMediaPreviewItem: NSObject, QLPreviewItem, Identifiable {
let timelineItem: EventBasedMessageTimelineItemProtocol
var fileHandle: MediaFileHandleProxy?
var downloadError: Error?
init(timelineItem: EventBasedMessageTimelineItemProtocol) {
self.timelineItem = timelineItem
}
init?(roomTimelineItemViewState: RoomTimelineItemViewState) {
switch roomTimelineItemViewState.type {
case .audio(let audioRoomTimelineItem):
timelineItem = audioRoomTimelineItem
case .file(let fileRoomTimelineItem):
timelineItem = fileRoomTimelineItem
case .image(let imageRoomTimelineItem):
timelineItem = imageRoomTimelineItem
case .video(let videoRoomTimelineItem):
timelineItem = videoRoomTimelineItem
default:
return nil
}
}
// MARK: Identifiable
var id: TimelineItemIdentifier { timelineItem.id }
// MARK: QLPreviewItem
var previewItemURL: URL? {
// Falling back to a clear image allows the presentation animation to work when
// the item is in the event cache and just needs to be loaded from the store.
fileHandle?.url ?? Bundle.main.url(forResource: "clear", withExtension: "png")
}
var previewItemTitle: String? {
filename
}
// MARK: Event details
var sender: TimelineItemSender {
timelineItem.sender
}
var timestamp: Date {
timelineItem.timestamp
}
// MARK: Media details
var mediaSource: MediaSourceProxy? {
switch timelineItem {
case let audioItem as AudioRoomTimelineItem:
audioItem.content.source
case let fileItem as FileRoomTimelineItem:
fileItem.content.source
case let imageItem as ImageRoomTimelineItem:
imageItem.content.imageInfo.source
case let videoItem as VideoRoomTimelineItem:
videoItem.content.videoInfo.source
default:
nil
}
}
var thumbnailMediaSource: MediaSourceProxy? {
switch timelineItem {
case let fileItem as FileRoomTimelineItem:
fileItem.content.thumbnailSource
case let imageItem as ImageRoomTimelineItem:
imageItem.content.thumbnailInfo?.source
case let videoItem as VideoRoomTimelineItem:
videoItem.content.thumbnailInfo?.source
default:
nil
}
}
var filename: String? {
switch timelineItem {
case let audioItem as AudioRoomTimelineItem:
audioItem.content.filename
case let fileItem as FileRoomTimelineItem:
fileItem.content.filename
case let imageItem as ImageRoomTimelineItem:
imageItem.content.filename
case let videoItem as VideoRoomTimelineItem:
videoItem.content.filename
default:
nil
}
}
var fileSize: Double? {
previewItemURL.flatMap { try? FileManager.default.sizeForItem(at: $0) } ?? expectedFileSize
}
private var expectedFileSize: Double? {
let fileSize: UInt? = switch timelineItem {
case let audioItem as AudioRoomTimelineItem:
audioItem.content.fileSize
case let fileItem as FileRoomTimelineItem:
fileItem.content.fileSize
case let imageItem as ImageRoomTimelineItem:
imageItem.content.imageInfo.fileSize
case let videoItem as VideoRoomTimelineItem:
videoItem.content.videoInfo.fileSize
default:
nil
}
return fileSize.map(Double.init)
}
var caption: String? {
timelineItem.mediaCaption
}
var contentType: String? {
switch timelineItem {
case let audioItem as AudioRoomTimelineItem:
audioItem.content.contentType?.localizedDescription
case let fileItem as FileRoomTimelineItem:
fileItem.content.contentType?.localizedDescription
case let imageItem as ImageRoomTimelineItem:
imageItem.content.contentType?.localizedDescription
case let videoItem as VideoRoomTimelineItem:
videoItem.content.contentType?.localizedDescription
default:
nil
}
}
var blurhash: String? {
switch timelineItem {
case let imageItem as ImageRoomTimelineItem:
imageItem.content.blurhash
case let videoItem as VideoRoomTimelineItem:
videoItem.content.blurhash
default:
nil
}
}
}
enum TimelineMediaPreviewViewAction {
case updateCurrentItem(TimelineMediaPreviewItem)
case updateCurrentItem(TimelineMediaPreviewItem?)
case showCurrentItemDetails
case menuAction(TimelineItemMenuAction, item: TimelineMediaPreviewItem)
case redactConfirmation(item: TimelineMediaPreviewItem)
@@ -35,24 +35,28 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
self.userIndicatorController = userIndicatorController
self.appMediator = appMediator
let previewItems = timelineViewModel.context.viewState.timelineState.itemViewStates.compactMap(TimelineMediaPreviewItem.init)
let initialItemIndex = previewItems.firstIndex { $0.id == context.item.id } ?? 0
let currentItem = previewItems[initialItemIndex]
super.init(initialViewState: TimelineMediaPreviewViewState(previewItems: previewItems,
initialItemIndex: initialItemIndex,
currentItem: currentItem,
super.init(initialViewState: TimelineMediaPreviewViewState(dataSource: .init(itemViewStates: timelineViewModel.context.viewState.timelineState.itemViewStates,
initialItem: context.item),
transitionNamespace: context.namespace),
mediaProvider: mediaProvider)
rebuildCurrentItemActions()
timelineViewModel.context.$viewState.map(\.canCurrentUserRedactSelf)
.merge(with: timelineViewModel.context.$viewState.map(\.canCurrentUserRedactOthers))
let canRedactSelfPublisher = timelineViewModel.context.$viewState.map(\.canCurrentUserRedactSelf)
let canRedactOthersPublisher = timelineViewModel.context.$viewState.map(\.canCurrentUserRedactOthers)
canRedactSelfPublisher.merge(with: canRedactOthersPublisher)
.sink { [weak self] _ in
self?.rebuildCurrentItemActions()
}
.store(in: &cancellables)
timelineViewModel.context.$viewState.map(\.timelineState.itemViewStates)
.removeDuplicates()
.sink { [weak self] itemViewStates in
self?.state.dataSource.updatePreviewItems(itemViewStates: itemViewStates)
}
.store(in: &cancellables)
}
override func process(viewAction: TimelineMediaPreviewViewAction) {
@@ -79,42 +83,48 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
}
}
private func updateCurrentItem(_ previewItem: TimelineMediaPreviewItem) async {
previewItem.downloadError = nil // Clear any existing error.
state.currentItem = previewItem
currentItemIDHandler?(previewItem.id)
private func updateCurrentItem(_ previewItem: TimelineMediaPreviewItem?) async {
previewItem?.downloadError = nil // Clear any existing error.
state.dataSource.updateCurrentItem(previewItem)
rebuildCurrentItemActions()
if previewItem.fileHandle == nil, let source = previewItem.mediaSource {
switch await mediaProvider.loadFileFromSource(source, filename: previewItem.filename) {
case .success(let handle):
previewItem.fileHandle = handle
state.fileLoadedPublisher.send(previewItem.id)
case .failure(let error):
MXLog.error("Failed loading media: \(error)")
context.objectWillChange.send() // Manually trigger the SwiftUI view update.
previewItem.downloadError = error
if let previewItem {
currentItemIDHandler?(previewItem.id)
if previewItem.fileHandle == nil, let source = previewItem.mediaSource {
switch await mediaProvider.loadFileFromSource(source, filename: previewItem.filename) {
case .success(let handle):
previewItem.fileHandle = handle
state.fileLoadedPublisher.send(previewItem.id)
case .failure(let error):
MXLog.error("Failed loading media: \(error)")
context.objectWillChange.send() // Manually trigger the SwiftUI view update.
previewItem.downloadError = error
}
}
}
}
private func rebuildCurrentItemActions() {
let timelineContext = timelineViewModel.context
let provider = TimelineItemMenuActionProvider(timelineItem: state.currentItem.timelineItem,
canCurrentUserRedactSelf: timelineContext.viewState.canCurrentUserRedactSelf,
canCurrentUserRedactOthers: timelineContext.viewState.canCurrentUserRedactOthers,
canCurrentUserPin: timelineContext.viewState.canCurrentUserPin,
pinnedEventIDs: timelineContext.viewState.pinnedEventIDs,
isDM: timelineContext.viewState.isEncryptedOneToOneRoom,
isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled,
timelineKind: timelineContext.viewState.timelineKind,
emojiProvider: timelineContext.viewState.emojiProvider)
state.currentItemActions = provider.makeActions()
state.currentItemActions = if let currentItem = state.currentItem {
TimelineItemMenuActionProvider(timelineItem: currentItem.timelineItem,
canCurrentUserRedactSelf: timelineContext.viewState.canCurrentUserRedactSelf,
canCurrentUserRedactOthers: timelineContext.viewState.canCurrentUserRedactOthers,
canCurrentUserPin: timelineContext.viewState.canCurrentUserPin,
pinnedEventIDs: timelineContext.viewState.pinnedEventIDs,
isDM: timelineContext.viewState.isEncryptedOneToOneRoom,
isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled,
timelineKind: timelineContext.viewState.timelineKind,
emojiProvider: timelineContext.viewState.emojiProvider)
.makeActions()
} else {
nil
}
}
private func saveCurrentItem() async {
guard let fileURL = state.currentItem.fileHandle?.url else {
guard let currentItem = state.currentItem, let fileURL = currentItem.fileHandle?.url else {
MXLog.error("Unable to save an item without a URL, the button shouldn't be visible.")
return
}
@@ -123,7 +133,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
state.bindings.mediaDetailsItem = nil
do {
switch state.currentItem.timelineItem {
switch currentItem.timelineItem {
case is AudioRoomTimelineItem, is FileRoomTimelineItem:
state.bindings.fileToExport = .init(url: fileURL)
return // Don't show the indicator.
@@ -177,27 +177,29 @@ struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePrevie
static let presentedOnRoomViewModel = makeViewModel(isPresentedOnRoomScreen: true)
static var previews: some View {
TimelineMediaPreviewDetailsView(item: viewModel.state.currentItem,
// swiftlint:disable force_unwrapping
TimelineMediaPreviewDetailsView(item: viewModel.state.currentItem!,
context: viewModel.context)
.previewDisplayName("Image")
.snapshotPreferences(expect: viewModel.context.$viewState.map { state in
state.currentItemActions?.secondaryActions.contains(.redact) ?? false
})
TimelineMediaPreviewDetailsView(item: loadingViewModel.state.currentItem,
TimelineMediaPreviewDetailsView(item: loadingViewModel.state.currentItem!,
context: loadingViewModel.context)
.previewDisplayName("Loading")
.snapshotPreferences(expect: loadingViewModel.context.$viewState.map { state in
state.currentItemActions?.secondaryActions.contains(.redact) ?? false
})
TimelineMediaPreviewDetailsView(item: unknownTypeViewModel.state.currentItem,
TimelineMediaPreviewDetailsView(item: unknownTypeViewModel.state.currentItem!,
context: unknownTypeViewModel.context)
.previewDisplayName("Unknown type")
TimelineMediaPreviewDetailsView(item: presentedOnRoomViewModel.state.currentItem,
TimelineMediaPreviewDetailsView(item: presentedOnRoomViewModel.state.currentItem!,
context: presentedOnRoomViewModel.context)
.previewDisplayName("Incoming on Room")
// swiftlint:enable force_unwrapping
}
static func makeViewModel(contentType: UTType? = nil,
@@ -125,7 +125,8 @@ struct TimelineMediaPreviewRedactConfirmationView_Previews: PreviewProvider, Tes
static let viewModel = makeViewModel(contentType: .jpeg)
static var previews: some View {
TimelineMediaPreviewRedactConfirmationView(item: viewModel.state.currentItem, context: viewModel.context)
// swiftlint:disable:next force_unwrapping
TimelineMediaPreviewRedactConfirmationView(item: viewModel.state.currentItem!, context: viewModel.context)
}
static func makeViewModel(contentType: UTType? = nil) -> TimelineMediaPreviewViewModel {
@@ -17,7 +17,7 @@ struct TimelineMediaPreviewScreen: View {
@State private var isFullScreen = false
private var toolbarVisibility: Visibility { isFullScreen ? .hidden : .visible }
private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem }
private var currentItem: TimelineMediaPreviewItem? { context.viewState.currentItem }
var body: some View {
NavigationStack {
@@ -40,7 +40,7 @@ struct TimelineMediaPreviewScreen: View {
.onDisappear {
itemIDHandler?(nil)
}
.zoomTransition(sourceID: currentItem.id, in: context.viewState.transitionNamespace)
.zoomTransition(sourceID: currentItem?.id, in: context.viewState.transitionNamespace)
}
var quickLookPreview: some View {
@@ -55,22 +55,25 @@ struct TimelineMediaPreviewScreen: View {
.safeAreaInset(edge: .bottom, spacing: 0) { caption }
}
@ViewBuilder
private var fullScreenButton: some View {
Button {
withAnimation { isFullScreen.toggle() }
} label: {
CompoundIcon(isFullScreen ? \.collapse : \.expand, size: .xSmall, relativeTo: .compound.bodyLG)
.padding(6)
.background(.thinMaterial, in: Circle())
if currentItem != nil {
Button {
withAnimation { isFullScreen.toggle() }
} label: {
CompoundIcon(isFullScreen ? \.collapse : \.expand, size: .xSmall, relativeTo: .compound.bodyLG)
.padding(6)
.background(.thinMaterial, in: Circle())
}
.tint(.compound.textActionPrimary)
.padding(.top, 12)
.padding(.trailing, 14)
}
.tint(.compound.textActionPrimary)
.padding(.top, 12)
.padding(.trailing, 14)
}
@ViewBuilder
private var downloadStatusIndicator: some View {
if currentItem.downloadError != nil {
if currentItem?.downloadError != nil {
VStack(spacing: 24) {
CompoundIcon(\.error, size: .custom(48), relativeTo: .compound.headingLG)
.foregroundStyle(.compound.iconCriticalPrimary)
@@ -91,7 +94,7 @@ struct TimelineMediaPreviewScreen: View {
.padding(.horizontal, 24)
.padding(.vertical, 40)
.background(.compound.bgSubtlePrimary, in: RoundedRectangle(cornerRadius: 14))
} else if currentItem.fileHandle == nil {
} else if currentItem?.fileHandle == nil {
ProgressView()
.controlSize(.large)
.tint(.compound.iconPrimary)
@@ -100,7 +103,7 @@ struct TimelineMediaPreviewScreen: View {
@ViewBuilder
private var caption: some View {
if let caption = currentItem.caption, !isFullScreen {
if let caption = currentItem?.caption, !isFullScreen {
Text(caption)
.font(.compound.bodyLG)
.foregroundStyle(.compound.textPrimary)
@@ -130,23 +133,32 @@ struct TimelineMediaPreviewScreen: View {
toolbarHeader
}
ToolbarItem(placement: .primaryAction) {
Button { context.send(viewAction: .showCurrentItemDetails) } label: {
CompoundIcon(\.info)
if currentItem != nil {
ToolbarItem(placement: .primaryAction) {
Button { context.send(viewAction: .showCurrentItemDetails) } label: {
CompoundIcon(\.info)
}
.tint(.compound.textActionPrimary)
}
.tint(.compound.textActionPrimary)
}
}
@ViewBuilder
private var toolbarHeader: some View {
VStack(spacing: 0) {
Text(currentItem.sender.displayName ?? currentItem.sender.id)
if let currentItem {
VStack(spacing: 0) {
Text(currentItem.sender.displayName ?? currentItem.sender.id)
.font(.compound.bodySMSemibold)
.foregroundStyle(.compound.textPrimary)
Text(currentItem.timestamp.formatted(date: .abbreviated, time: .omitted))
.font(.compound.bodyXS)
.foregroundStyle(.compound.textPrimary)
.textCase(.uppercase)
}
} else {
Text(L10n.commonLoadingMore)
.font(.compound.bodySMSemibold)
.foregroundStyle(.compound.textPrimary)
Text(currentItem.timestamp.formatted(date: .abbreviated, time: .omitted))
.font(.compound.bodyXS)
.foregroundStyle(.compound.textPrimary)
.textCase(.uppercase)
}
}
}
@@ -156,14 +168,11 @@ struct TimelineMediaPreviewScreen: View {
private struct QuickLookView: UIViewControllerRepresentable {
let viewModelContext: TimelineMediaPreviewViewModel.Context
func makeUIViewController(context: Context) -> PreviewController {
let fileLoadedPublisher = viewModelContext.viewState.fileLoadedPublisher.eraseToAnyPublisher()
let controller = PreviewController(coordinator: context.coordinator, fileLoadedPublisher: fileLoadedPublisher)
controller.currentPreviewItemIndex = viewModelContext.viewState.initialItemIndex
return controller
func makeUIViewController(context: Context) -> QLPreviewController {
context.coordinator.previewController
}
func updateUIViewController(_ uiViewController: PreviewController, context: Context) { }
func updateUIViewController(_ uiViewController: QLPreviewController, context: Context) { }
func makeCoordinator() -> Coordinator {
Coordinator(viewModelContext: viewModelContext)
@@ -171,55 +180,57 @@ private struct QuickLookView: UIViewControllerRepresentable {
// MARK: Coordinator
class Coordinator: NSObject, QLPreviewControllerDataSource, QLPreviewControllerDelegate {
@MainActor class Coordinator {
let previewController = QLPreviewController()
private let viewModelContext: TimelineMediaPreviewViewModel.Context
private var cancellables: Set<AnyCancellable> = []
init(viewModelContext: TimelineMediaPreviewViewModel.Context) {
self.viewModelContext = viewModelContext
}
func updateCurrentItem(_ item: TimelineMediaPreviewItem) {
viewModelContext.send(viewAction: .updateCurrentItem(item))
}
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
viewModelContext.viewState.previewItems.count
}
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
viewModelContext.viewState.previewItems[index]
}
}
// MARK: UIKit
class PreviewController: QLPreviewController {
private var cancellables: Set<AnyCancellable> = []
init(coordinator: Coordinator, fileLoadedPublisher: AnyPublisher<TimelineItemIdentifier, Never>) {
super.init(nibName: nil, bundle: nil)
dataSource = coordinator
delegate = coordinator
// Observation of currentPreviewItem doesn't work, so use the index instead.
publisher(for: \.currentPreviewItemIndex)
previewController.publisher(for: \.currentPreviewItemIndex)
.sink { [weak self] _ in
guard let self, let currentPreviewItem = currentPreviewItem as? TimelineMediaPreviewItem else { return }
coordinator.updateCurrentItem(currentPreviewItem)
// This isn't removing duplicates which may try to download and/or write to disk concurrently????
self?.loadCurrentItem()
}
.store(in: &cancellables)
fileLoadedPublisher
.sink { [weak self] itemID in
guard let self, (currentPreviewItem as? TimelineMediaPreviewItem)?.id == itemID else { return }
refreshCurrentPreviewItem()
viewModelContext.viewState.dataSource.previewItemsPaginationPublisher
.sink { [weak self] in
self?.handleUpdatedItems()
}
.store(in: &cancellables)
viewModelContext.viewState.fileLoadedPublisher
.sink { [weak self] itemID in
self?.handleFileLoaded(itemID: itemID)
}
.store(in: &cancellables)
previewController.dataSource = viewModelContext.viewState.dataSource
previewController.currentPreviewItemIndex = viewModelContext.viewState.dataSource.initialItemIndex
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
private func loadCurrentItem() {
viewModelContext.send(viewAction: .updateCurrentItem(previewController.currentPreviewItem as? TimelineMediaPreviewItem))
}
private func handleUpdatedItems() {
if previewController.currentPreviewItem is TimelineMediaPreviewLoadingItem {
let dataSource = viewModelContext.viewState.dataSource
if dataSource.previewController(previewController, previewItemAt: previewController.currentPreviewItemIndex) is TimelineMediaPreviewItem {
previewController.refreshCurrentPreviewItem() // This will trigger loadCurrentItem automatically.
}
}
}
private func handleFileLoaded(itemID: TimelineItemIdentifier) {
guard (previewController.currentPreviewItem as? TimelineMediaPreviewItem)?.id == itemID else { return }
previewController.refreshCurrentPreviewItem()
}
}
}