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
@@ -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()
}
}
}