From 632d8a7aa3bc7c2d1ea91f5c222845bce0b89526 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 27 Aug 2026 11:10:37 +1200 Subject: [PATCH 1/4] Expose the staged file URL on upload entries Pending and failed entries carry the materialized temp file URL so the Uploads screen can render a thumbnail. It is nil until materialization completes and stays nil for non-retryable failures. --- .../Models/FailedUpload.swift | 3 + .../Models/PendingUpload.swift | 3 + .../Upload/MediaUploader.swift | 6 +- .../MediaUploaderTests.swift | 74 +++++++++++++++++++ 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/WordPressMediaLibrary/Models/FailedUpload.swift b/Modules/Sources/WordPressMediaLibrary/Models/FailedUpload.swift index 9e5a1871e15a..76db710348d0 100644 --- a/Modules/Sources/WordPressMediaLibrary/Models/FailedUpload.swift +++ b/Modules/Sources/WordPressMediaLibrary/Models/FailedUpload.swift @@ -13,4 +13,7 @@ struct FailedUpload: Identifiable, Sendable { /// `MediaCreateParams` / temp file were never produced — the /// Uploads-screen row should offer Dismiss only. let isRetryable: Bool + /// Materialized temp file on disk; non-nil exactly when `isRetryable` + /// (both derive from the materialized payload surviving the failure). + let localFileURL: URL? } diff --git a/Modules/Sources/WordPressMediaLibrary/Models/PendingUpload.swift b/Modules/Sources/WordPressMediaLibrary/Models/PendingUpload.swift index d51eca8516df..5901a461b328 100644 --- a/Modules/Sources/WordPressMediaLibrary/Models/PendingUpload.swift +++ b/Modules/Sources/WordPressMediaLibrary/Models/PendingUpload.swift @@ -8,4 +8,7 @@ struct PendingUpload: Identifiable, Sendable { let displayName: String // basename of the temp file let kind: MediaKind // for icon + Uploads-row rendering let progress: Progress // bound to ProgressView directly + /// Materialized temp file on disk; nil until materialization completes. + /// Drives the Uploads-row thumbnail. + let localFileURL: URL? } diff --git a/Modules/Sources/WordPressMediaLibrary/Upload/MediaUploader.swift b/Modules/Sources/WordPressMediaLibrary/Upload/MediaUploader.swift index 762a3edc2392..54eb88c9b959 100644 --- a/Modules/Sources/WordPressMediaLibrary/Upload/MediaUploader.swift +++ b/Modules/Sources/WordPressMediaLibrary/Upload/MediaUploader.swift @@ -422,7 +422,8 @@ private struct InternalPending { id: id, displayName: materialized?.displayName ?? displayName, kind: materialized?.kind ?? kind, - progress: overallProgress + progress: overallProgress, + localFileURL: materialized?.tempFileURL ) } } @@ -440,7 +441,8 @@ private struct InternalFailed { displayName: materialized?.displayName ?? displayName, kind: materialized?.kind ?? kind, errorMessage: errorMessage, - isRetryable: materialized != nil + isRetryable: materialized != nil, + localFileURL: materialized?.tempFileURL ) } } diff --git a/Modules/Tests/WordPressMediaLibraryTests/MediaUploaderTests.swift b/Modules/Tests/WordPressMediaLibraryTests/MediaUploaderTests.swift index e7ccf136cac9..264fd3b302bb 100644 --- a/Modules/Tests/WordPressMediaLibraryTests/MediaUploaderTests.swift +++ b/Modules/Tests/WordPressMediaLibraryTests/MediaUploaderTests.swift @@ -539,4 +539,78 @@ final class MediaUploaderTests { await uploader.updatePolicy(makePolicy(filePickerContentTypes: [.pdf])) #expect(uploader.filePickerContentTypes == [.pdf]) } + + @Test("localFileURL is nil before materialization and set after") + func localFileURLAppearsAfterMaterialization() async throws { + let transport = BlockingFakeUploadTransport() + let mock = MockMaterializer() + let uploader = MediaUploader( + transport: transport, + materializer: mock, + filePickerContentTypes: [.content] + ) + + let pdfURL = try fixtures.writePDF() + + await uploader.enqueue(sources: [.file(pdfURL)]) + await mock.waitForStart() + + let before = await uploader.snapshot() + #expect(before.pending.first?.localFileURL == nil) + + let realTemp = try fixtures.writeFile(name: "materialized.bin", content: Data("payload".utf8)) + let materialized = MaterializedUpload( + tempFileURL: realTemp, + params: MediaCreateParams(filePath: realTemp.path), + kind: .document + ) + await mock.complete(with: .success(materialized)) + try await Task.sleep(for: .milliseconds(50)) + + // Transport is still blocked, so the entry is pending with a file on disk. + let after = await uploader.snapshot() + #expect(after.pending.first?.localFileURL == realTemp) + + await transport.unblock() + } + + @Test("upload-stage failure keeps localFileURL on the retryable failed entry") + func uploadFailureKeepsLocalFileURL() async throws { + let transport = FakeUploadTransport() + await transport.setResponses([.failure(URLError(.timedOut))]) + let uploader = MediaUploader(transport: transport, policy: makeAllowEverythingPolicy()) + + let sourceURL = try fixtures.writePDF(name: "fail.pdf") + + await uploader.enqueue(sources: [.file(sourceURL)]) + try await Task.sleep(for: .milliseconds(200)) + + let state = await uploader.snapshot() + #expect(state.failed.count == 1) + #expect(state.failed[0].isRetryable) + #expect(state.failed[0].localFileURL != nil) + } + + @Test("materialization failure yields no localFileURL") + func materializationFailureHasNoLocalFileURL() async throws { + let transport = FakeUploadTransport() + let mock = MockMaterializer() + let uploader = MediaUploader( + transport: transport, + materializer: mock, + filePickerContentTypes: [.content] + ) + + let pdfURL = try fixtures.writePDF() + + await uploader.enqueue(sources: [.file(pdfURL)]) + await mock.waitForStart() + await mock.complete(with: .failure(URLError(.cannotOpenFile))) + try await Task.sleep(for: .milliseconds(50)) + + let state = await uploader.snapshot() + #expect(state.failed.count == 1) + #expect(!state.failed[0].isRetryable) + #expect(state.failed[0].localFileURL == nil) + } } From cbaf58047666850dce3afd55373e67d849bd0682 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 27 Aug 2026 11:10:37 +1200 Subject: [PATCH 2/4] Add MediaUploaderRegistry and the upload policy factory The registry caches one MediaUploader per blog so in-flight uploads survive navigation, and pushes a freshly built policy into a cached uploader on every vend so changed media settings apply to new enqueues. The factory derives the policy from the blog's allowed types and the user's media settings. --- .../Media/MediaUploaderRegistryTests.swift | 58 +++++++++++++++++++ .../Media/V2/MediaUploadPolicyFactory.swift | 35 +++++++++++ .../Media/V2/MediaUploaderRegistry.swift | 54 +++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 Tests/KeystoneTests/Tests/Features/Media/MediaUploaderRegistryTests.swift create mode 100644 WordPress/Classes/ViewRelated/Media/V2/MediaUploadPolicyFactory.swift create mode 100644 WordPress/Classes/ViewRelated/Media/V2/MediaUploaderRegistry.swift diff --git a/Tests/KeystoneTests/Tests/Features/Media/MediaUploaderRegistryTests.swift b/Tests/KeystoneTests/Tests/Features/Media/MediaUploaderRegistryTests.swift new file mode 100644 index 000000000000..b277db213a7c --- /dev/null +++ b/Tests/KeystoneTests/Tests/Features/Media/MediaUploaderRegistryTests.swift @@ -0,0 +1,58 @@ +import Foundation +import CoreData +import Testing +import WordPressData +@testable import WordPress + +@Suite("MediaUploaderRegistry", .serialized) +@MainActor +struct MediaUploaderRegistryTests { + let contextManager = ContextManager.forTesting() + var mainContext: NSManagedObjectContext { contextManager.mainContext } + + // WordPressSite(blog:) for a dotCom blog requires a non-nil dotComID and + // account.authToken so it can construct the .dotCom case without hitting + // the keychain. + private func makeBlog(siteId: Int) -> Blog { + let blog = ModelTestHelper.insertDotComBlog(context: mainContext) + blog.dotComID = siteId as NSNumber + blog.account?.authToken = "test-token" + // TaggedManagedObjectID(blog) requires a permanent ID; save the + // context so Core Data assigns one before the blog is keyed. + contextManager.saveContextAndWait(mainContext) + return blog + } + + @Test("Returns the same uploader for the same blog") + func sameBlogReturnsSameUploader() throws { + let blog = makeBlog(siteId: 1) + let registry = MediaUploaderRegistry() + let first = try registry.uploader(for: blog) + let second = try registry.uploader(for: blog) + #expect(first === second) + } + + @Test("tearDown removes only the targeted blog's uploader") + func tearDownIsTargeted() async throws { + let blogA = makeBlog(siteId: 1) + let blogB = makeBlog(siteId: 2) + let registry = MediaUploaderRegistry() + _ = try registry.uploader(for: blogA) + let bUploader = try registry.uploader(for: blogB) + await registry.tearDown(blogID: TaggedManagedObjectID(blogA)) + let bAgain = try registry.uploader(for: blogB) + #expect(bUploader === bAgain) + } + + @Test("tearDownAll clears every uploader") + func tearDownAllClears() async throws { + let blogA = makeBlog(siteId: 1) + let blogB = makeBlog(siteId: 2) + let registry = MediaUploaderRegistry() + let aFirst = try registry.uploader(for: blogA) + _ = try registry.uploader(for: blogB) + await registry.tearDownAll() + let aSecond = try registry.uploader(for: blogA) + #expect(aFirst !== aSecond) + } +} diff --git a/WordPress/Classes/ViewRelated/Media/V2/MediaUploadPolicyFactory.swift b/WordPress/Classes/ViewRelated/Media/V2/MediaUploadPolicyFactory.swift new file mode 100644 index 000000000000..5737a6522e34 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/V2/MediaUploadPolicyFactory.swift @@ -0,0 +1,35 @@ +import Foundation +import UniformTypeIdentifiers +import WordPressData +import WordPressMediaLibrary + +@MainActor +enum MediaUploadPolicyFactory { + static func make(from blog: Blog) -> MediaUploadPolicy { + let pickerTypes = blog.allowedTypeIdentifiers.compactMap { UTType($0) } + + let serverAllowed: Set = blog.allowedFileTypes + let defaultAllowed = MediaImportService.defaultAllowableFileExtensions + + let mediaSettings = MediaSettings() + let configuredMaxDim = mediaSettings.imageSizeForUpload + let imageMax: Int? = configuredMaxDim < Int.max ? configuredMaxDim : nil + + return MediaUploadPolicy( + filePickerContentTypes: pickerTypes, + isAllowedForUpload: { _, fileExtension in + let ext = fileExtension.lowercased() + if defaultAllowed.contains(ext) { return true } + if serverAllowed.isEmpty { return true } + return serverAllowed.contains(ext) + }, + imageMaxDimension: imageMax, + imageJpegQuality: mediaSettings.imageQualityForUpload.doubleValue, + convertHEICToJPEG: true, + videoMaxDurationSeconds: blog.videoDurationLimit, + videoExportPreset: mediaSettings.maxVideoSizeSetting.videoPreset, + videoOutputContentType: .mpeg4Movie, + stripGPSLocation: mediaSettings.removeLocationSetting + ) + } +} diff --git a/WordPress/Classes/ViewRelated/Media/V2/MediaUploaderRegistry.swift b/WordPress/Classes/ViewRelated/Media/V2/MediaUploaderRegistry.swift new file mode 100644 index 000000000000..5bd1883757cb --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/V2/MediaUploaderRegistry.swift @@ -0,0 +1,54 @@ +import Foundation +import WordPressCore +import WordPressData +import WordPressMediaLibrary + +@MainActor +final class MediaUploaderRegistry { + static let shared = MediaUploaderRegistry() + + private var uploaders: [TaggedManagedObjectID: MediaUploader] = [:] + private let clientFactory: WordPressClientFactory + + init(clientFactory: WordPressClientFactory = .shared) { + self.clientFactory = clientFactory + } + + func uploader(for blog: Blog) throws -> MediaUploader { + let id = TaggedManagedObjectID(blog) + // Built here, before any async hop, because the factory reads the + // Blog on its main managed object context; only the Sendable policy + // value crosses into the update Task below. + let policy = MediaUploadPolicyFactory.make(from: blog) + if let existing = uploaders[id] { + // MediaSettings (like Remove Location) or refreshed blog options + // may have changed since this uploader was cached. Push a fresh + // policy so new enqueues honor them. + Task { await existing.updatePolicy(policy) } + return existing + } + + let site = try WordPressSite(blog: blog) + let client = clientFactory.instance(for: site) + let uploader = MediaUploader(client: client, policy: policy) + uploaders[id] = uploader + return uploader + } + + /// Removal call sites should derive the `TaggedManagedObjectID` from + /// the `Blog` while still on its managed object context, then pass it + /// here. Capturing the `Blog` itself across the launched `Task`'s + /// async boundary risks resolving a deleted-or-wrong-context object. + func tearDown(blogID: TaggedManagedObjectID) async { + guard let uploader = uploaders.removeValue(forKey: blogID) else { return } + await uploader.tearDown() + } + + func tearDownAll() async { + let snapshot = uploaders + uploaders.removeAll() + for (_, uploader) in snapshot { + await uploader.tearDown() + } + } +} From 75cb55d2248989bdb1785fec4484ae4483f50caf Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 27 Aug 2026 11:10:37 +1200 Subject: [PATCH 3/4] Sweep orphaned V2 upload staging files at launch --- WordPress/Classes/System/WordPressAppDelegate.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/WordPress/Classes/System/WordPressAppDelegate.swift b/WordPress/Classes/System/WordPressAppDelegate.swift index 7821bcdc7dfe..a4ec87fca2c2 100644 --- a/WordPress/Classes/System/WordPressAppDelegate.swift +++ b/WordPress/Classes/System/WordPressAppDelegate.swift @@ -15,6 +15,7 @@ import UIKit import WebKit import WordPressData import WordPressKit +import WordPressMediaLibrary import WordPressShared import WordPressUI import ZendeskCoreSDK @@ -400,6 +401,10 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { self?.mergeDuplicateAccountsIfNeeded() MediaCoordinator.shared.refreshMediaStatus() MediaFileManager.clearUnusedMediaUploadFiles(onCompletion: nil, onError: nil) + // V2 uploads stage into persistent storage; clear anything a + // prior crash/force-quit orphaned there (no in-flight uploads + // survive process termination). + MediaUploader.sweepOrphanedStagingFiles() } DispatchQueue.main.asyncAfter(deadline: .now() + 3) { From 5bc659432f5fa3f656f8817898b78caa68b0f2b7 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 27 Aug 2026 11:10:38 +1200 Subject: [PATCH 4/4] Add upload UI and wire the uploader into the grid Add an add-menu with photo library, camera, file, and app-supplied external pickers; a banner above the grid summarizing pending and failed uploads; and an Uploads sheet with per-row cancel, retry, and remove plus bulk actions. The view model observes the uploader's state publisher and maps snapshots into row items. The routing vends the site's uploader from the registry into the hosting controller. --- .../Models/ExternalRemoteMedia.swift | 20 +++ .../Preferences/AspectRatioPreference.swift | 1 + .../Strings/Strings.swift | 22 +-- .../Views/BannerView.swift | 44 +++++ .../Views/CameraPickerRepresentable.swift | 61 +++++++ .../Views/ExternalMediaPickerSupport.swift | 33 ++++ .../Views/MediaLibraryHostingController.swift | 19 +- .../Views/MediaLibraryView.swift | 143 +++++++++++++++- .../Views/MediaLibraryViewModel.swift | 162 +++++++++++++++++- .../Views/PhotosPickerRepresentable.swift | 47 +++++ .../Views/UploadRow.swift | 92 ++++++++++ .../Views/UploadThumbnailView.swift | 49 ++++++ .../Views/UploadsView.swift | 91 ++++++++++ .../Media/MediaLibraryRouting.swift | 14 +- 14 files changed, 776 insertions(+), 22 deletions(-) create mode 100644 Modules/Sources/WordPressMediaLibrary/Models/ExternalRemoteMedia.swift create mode 100644 Modules/Sources/WordPressMediaLibrary/Views/BannerView.swift create mode 100644 Modules/Sources/WordPressMediaLibrary/Views/CameraPickerRepresentable.swift create mode 100644 Modules/Sources/WordPressMediaLibrary/Views/ExternalMediaPickerSupport.swift create mode 100644 Modules/Sources/WordPressMediaLibrary/Views/PhotosPickerRepresentable.swift create mode 100644 Modules/Sources/WordPressMediaLibrary/Views/UploadRow.swift create mode 100644 Modules/Sources/WordPressMediaLibrary/Views/UploadThumbnailView.swift create mode 100644 Modules/Sources/WordPressMediaLibrary/Views/UploadsView.swift diff --git a/Modules/Sources/WordPressMediaLibrary/Models/ExternalRemoteMedia.swift b/Modules/Sources/WordPressMediaLibrary/Models/ExternalRemoteMedia.swift new file mode 100644 index 000000000000..b146dbbbbcae --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Models/ExternalRemoteMedia.swift @@ -0,0 +1,20 @@ +import Foundation +import UniformTypeIdentifiers + +/// Public boundary payload that app-target external pickers (Stock Photos) +/// construct and pass through `ExternalMediaPickerDelegate`. The +/// module's view model converts this to `UploadSource.remoteURL(_)` before +/// enqueueing — keeps the internal `UploadSource` enum out of the public API. +public struct ExternalRemoteMedia: Sendable { + public let url: URL + public let suggestedName: String + public let contentType: UTType + public let caption: String? + + public init(url: URL, suggestedName: String, contentType: UTType, caption: String?) { + self.url = url + self.suggestedName = suggestedName + self.contentType = contentType + self.caption = caption + } +} diff --git a/Modules/Sources/WordPressMediaLibrary/Preferences/AspectRatioPreference.swift b/Modules/Sources/WordPressMediaLibrary/Preferences/AspectRatioPreference.swift index cc5eb57f77c8..b8c48fd2a88e 100644 --- a/Modules/Sources/WordPressMediaLibrary/Preferences/AspectRatioPreference.swift +++ b/Modules/Sources/WordPressMediaLibrary/Preferences/AspectRatioPreference.swift @@ -12,6 +12,7 @@ import WordPressShared enum AspectRatioPreference { private static let key = UPRUConstants.mediaAspectRatioModeEnabledKey + @MainActor static func load(defaults: UserDefaults = .standard) -> Bool { if let value = defaults.object(forKey: key) as? Bool { return value } return UIDevice.current.userInterfaceIdiom == .pad diff --git a/Modules/Sources/WordPressMediaLibrary/Strings/Strings.swift b/Modules/Sources/WordPressMediaLibrary/Strings/Strings.swift index 34bb82385f0d..7fa0af6b827a 100644 --- a/Modules/Sources/WordPressMediaLibrary/Strings/Strings.swift +++ b/Modules/Sources/WordPressMediaLibrary/Strings/Strings.swift @@ -236,10 +236,10 @@ enum Strings { value: "Uploads", comment: "Navigation title for the Uploads queue screen." ) - static let uploadsScreenAllDone = NSLocalizedString( - "mediaLibrary.uploads.allDone", - value: "All uploaded", - comment: "Empty-state label shown on the Uploads screen after the last item resolves." + static let uploadsScreenEmpty = NSLocalizedString( + "mediaLibrary.uploads.empty", + value: "No Uploads", + comment: "Empty-state label shown on the Uploads screen when the queue has no items." ) static let uploadsScreenClose = NSLocalizedString( "mediaLibrary.uploads.close", @@ -266,9 +266,9 @@ enum Strings { value: "Retry", comment: "Per-row action: retry a failed upload." ) - static let uploadActionDismiss = NSLocalizedString( - "mediaLibrary.uploads.dismiss", - value: "Dismiss", + static let uploadActionRemove = NSLocalizedString( + "mediaLibrary.uploads.remove", + value: "Remove", comment: "Per-row action: remove a failed upload from the queue." ) static let uploadBulkRetryAll = NSLocalizedString( @@ -276,10 +276,10 @@ enum Strings { value: "Retry all failed", comment: "Bulk action: retry every failed upload." ) - static let uploadBulkDismissAll = NSLocalizedString( - "mediaLibrary.uploads.bulk.dismissAll", - value: "Dismiss all failed", - comment: "Bulk action: dismiss every failed upload." + static let uploadBulkRemoveAll = NSLocalizedString( + "mediaLibrary.uploads.bulk.removeAll", + value: "Remove all failed", + comment: "Bulk action: remove every failed upload from the queue." ) static let uploadBulkCancelAll = NSLocalizedString( "mediaLibrary.uploads.bulk.cancelAll", diff --git a/Modules/Sources/WordPressMediaLibrary/Views/BannerView.swift b/Modules/Sources/WordPressMediaLibrary/Views/BannerView.swift new file mode 100644 index 000000000000..94d745cbd5dd --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Views/BannerView.swift @@ -0,0 +1,44 @@ +import SwiftUI + +struct BannerView: View { + let summary: MediaLibraryViewModel.BannerSummary + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack(spacing: 12) { + if summary.pendingCount > 0 { + ProgressView() + .progressViewStyle(.circular) + .controlSize(.small) + } + Text(label) + .font(.subheadline) + Spacer() + Image(systemName: "chevron.right") + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.thinMaterial, in: .rect(cornerRadius: 12)) + } + .buttonStyle(.plain) + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + + private var label: String { + switch (summary.pendingCount, summary.failedCount) { + case (let p, 0) where p > 0: + let template = p == 1 ? Strings.uploadBannerUploadingOnlySingle : Strings.uploadBannerUploadingOnly + return String.localizedStringWithFormat(template, p) + case (let p, let f) where p > 0 && f > 0: + return String.localizedStringWithFormat(Strings.uploadBannerMixed, p, f) + case (0, let f) where f > 0: + let template = f == 1 ? Strings.uploadBannerFailedOnlySingle : Strings.uploadBannerFailedOnly + return String.localizedStringWithFormat(template, f) + default: + return "" + } + } +} diff --git a/Modules/Sources/WordPressMediaLibrary/Views/CameraPickerRepresentable.swift b/Modules/Sources/WordPressMediaLibrary/Views/CameraPickerRepresentable.swift new file mode 100644 index 000000000000..89830fd57c5c --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Views/CameraPickerRepresentable.swift @@ -0,0 +1,61 @@ +import SwiftUI +import UIKit +import UniformTypeIdentifiers + +struct CameraPickerRepresentable: UIViewControllerRepresentable { + enum Mode { case photo, video } + let mode: Mode + let onPicked: (UploadSource) -> Void + let onCancel: () -> Void + + func makeUIViewController(context: Context) -> UIImagePickerController { + let controller = UIImagePickerController() + controller.sourceType = .camera + controller.mediaTypes = [ + mode == .photo ? UTType.image.identifier : UTType.movie.identifier + ] + if mode == .video { + controller.videoQuality = .typeHigh + } + controller.delegate = context.coordinator + return controller + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} + + func makeCoordinator() -> Coordinator { Coordinator(parent: self) } + + final class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { + let parent: CameraPickerRepresentable + + init(parent: CameraPickerRepresentable) { + self.parent = parent + } + + func imagePickerController( + _ picker: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] + ) { + picker.dismiss(animated: true) + switch parent.mode { + case .photo: + if let image = info[.originalImage] as? UIImage { + parent.onPicked(.cameraImage(image, capturedAt: Date())) + } else { + parent.onCancel() + } + case .video: + if let url = info[.mediaURL] as? URL { + parent.onPicked(.cameraVideo(url, capturedAt: Date())) + } else { + parent.onCancel() + } + } + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + picker.dismiss(animated: true) + parent.onCancel() + } + } +} diff --git a/Modules/Sources/WordPressMediaLibrary/Views/ExternalMediaPickerSupport.swift b/Modules/Sources/WordPressMediaLibrary/Views/ExternalMediaPickerSupport.swift new file mode 100644 index 000000000000..2fb82cec0b3e --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Views/ExternalMediaPickerSupport.swift @@ -0,0 +1,33 @@ +import SwiftUI + +/// Public delegate protocol the app-target picker sheets call into. +/// `MediaLibraryViewModel` conforms internally; the app target only sees +/// this protocol existential via `ExternalMediaPickerOption.sheetContent`. +@MainActor +public protocol ExternalMediaPickerDelegate: AnyObject { + func didPick(remoteMedia: [ExternalRemoteMedia]) + func didPick(imagePlaygroundFile url: URL, suggestedName: String) + func didCancel() +} + +/// Public extension point that `MediaLibraryView`'s add-menu iterates. +/// `MediaLibraryRouting` constructs one of these per external source the +/// app target wants to offer (Stock Photos, Image Playground). +public struct ExternalMediaPickerOption: Identifiable { + public let id: String + public let label: String + public let systemImage: String + public let sheetContent: @MainActor (_ delegate: any ExternalMediaPickerDelegate) -> AnyView + + public init( + id: String, + label: String, + systemImage: String, + sheetContent: @escaping @MainActor (_ delegate: any ExternalMediaPickerDelegate) -> AnyView + ) { + self.id = id + self.label = label + self.systemImage = systemImage + self.sheetContent = sheetContent + } +} diff --git a/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryHostingController.swift b/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryHostingController.swift index 0f75e9545ad1..540cbbd496fe 100644 --- a/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryHostingController.swift +++ b/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryHostingController.swift @@ -12,9 +12,16 @@ public enum MediaLibraryHostingController { @MainActor public static func make( client: WordPressClient, - tracker: any MediaTracker + tracker: any MediaTracker, + uploader: MediaUploader, + externalPickerOptions: [ExternalMediaPickerOption] = [] ) -> UIViewController { - let view = MediaLibraryContainerView(client: client, tracker: tracker) + let view = MediaLibraryContainerView( + client: client, + tracker: tracker, + uploader: uploader, + externalPickerOptions: externalPickerOptions + ) let host = UIHostingController(rootView: view) host.navigationItem.largeTitleDisplayMode = .never return host @@ -30,6 +37,8 @@ public enum MediaLibraryHostingController { private struct MediaLibraryContainerView: View { let client: WordPressClient let tracker: any MediaTracker + let uploader: MediaUploader + let externalPickerOptions: [ExternalMediaPickerOption] @State private var resolved: Resolved? @State private var error: Error? @@ -48,7 +57,8 @@ private struct MediaLibraryContainerView: View { viewModel: resolved.viewModel, service: resolved.service, client: client, - tracker: tracker + tracker: tracker, + externalPickerOptions: externalPickerOptions ) } else if let error { EmptyStateView.failure(error: error) { @@ -66,7 +76,8 @@ private struct MediaLibraryContainerView: View { viewModel: MediaLibraryViewModel( service: service, client: client, - tracker: tracker + tracker: tracker, + uploader: uploader ), service: service ) diff --git a/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryView.swift b/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryView.swift index 07664755804a..dce03bb7bd71 100644 --- a/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryView.swift +++ b/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryView.swift @@ -1,5 +1,6 @@ import DesignSystem import SwiftUI +import UIKit import WordPressAPI import WordPressAPIInternal import WordPressCore @@ -13,19 +14,39 @@ struct MediaLibraryView: View { let service: WpService let client: WordPressClient let tracker: any MediaTracker + var externalPickerOptions: [ExternalMediaPickerOption] = [] @State private var searchText = "" @State private var isAspectRatioMode = AspectRatioPreference.load() /// Incremented by the Retry button so its `.task(id:)` re-fires; the initial /// value 0 is ignored so we don't double-load on appearance. @State private var retryToken = 0 + @State private var activePicker: ActivePicker? + /// Drives `.fileImporter` directly rather than through `ActivePicker`: + /// the importer needs a real binding it can write `false` into on user + /// cancel, which never invokes `onCompletion`. + @State private var isImportingFile = false + @State private var isPresentingUploads = false + + private enum ActivePicker: Hashable, Identifiable { + case photoLibrary, takePhoto, takeVideo + case external(id: String) + var id: Self { self } + } var body: some View { ZStack { if searchText.isEmpty { - MediaGridView(items: viewModel.displayItems, isAspectRatioMode: isAspectRatioMode) - .refreshable { await viewModel.refresh() } - .overlay { libraryOverlay } + VStack(spacing: 0) { + if let summary = viewModel.bannerSummary { + BannerView(summary: summary) { + isPresentingUploads = true + } + } + MediaGridView(items: viewModel.displayItems, isAspectRatioMode: isAspectRatioMode) + .refreshable { await viewModel.refresh() } + .overlay { libraryOverlay } + } } else { MediaLibrarySearchView( service: service, @@ -51,7 +72,85 @@ struct MediaLibraryView: View { .minimizedSearchToolbarBehavior() .autocorrectionDisabled() .textInputAutocapitalization(.never) - .toolbar { filterMenu } + .toolbar { + filterMenu + addMenu + } + // `MediaLibraryView` is hosted in a UIKit `UINavigationController` + // via `UIHostingController`, so there's no SwiftUI `NavigationStack` + // ancestor for `.navigationDestination` to push into. Present the + // Uploads queue as a sheet instead — it's a self-contained + // management surface (its own toolbar + bulk menu) and survives + // the SwiftUI/UIKit boundary cleanly. + .sheet(isPresented: $isPresentingUploads) { + NavigationStack { + UploadsView(viewModel: viewModel) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + if #available(iOS 26, *) { + Button(role: .close) { + isPresentingUploads = false + } + } else { + Button { + isPresentingUploads = false + } label: { + Image(systemName: "xmark") + } + .accessibilityLabel(Strings.uploadsScreenClose) + } + } + } + } + } + .sheet(item: $activePicker) { picker in + switch picker { + case .photoLibrary: + PhotosPickerRepresentable( + onPicked: { sources in + activePicker = nil + Task { await viewModel.enqueue(sources: sources) } + }, + onCancel: { activePicker = nil } + ) + .ignoresSafeArea() + case .takePhoto: + CameraPickerRepresentable( + mode: .photo, + onPicked: { source in + activePicker = nil + Task { await viewModel.enqueue(sources: [source]) } + }, + onCancel: { activePicker = nil } + ) + .ignoresSafeArea() + case .takeVideo: + CameraPickerRepresentable( + mode: .video, + onPicked: { source in + activePicker = nil + Task { await viewModel.enqueue(sources: [source]) } + }, + onCancel: { activePicker = nil } + ) + .ignoresSafeArea() + case .external(let id): + if let option = externalPickerOptions.first(where: { $0.id == id }) { + option.sheetContent(viewModel) + } + } + } + .fileImporter( + isPresented: $isImportingFile, + allowedContentTypes: viewModel.uploader?.filePickerContentTypes ?? [], + allowsMultipleSelection: true, + onCompletion: { result in + if case .success(let urls) = result { + let sources = urls.map { UploadSource.file($0) } + Task { await viewModel.enqueue(sources: sources) } + } + } + ) } @ToolbarContentBuilder private var filterMenu: some ToolbarContent { @@ -93,6 +192,42 @@ struct MediaLibraryView: View { } } + @ToolbarContentBuilder private var addMenu: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button(Strings.addMenuPhotoLibrary, systemImage: "photo.on.rectangle") { + activePicker = .photoLibrary + } + if UIImagePickerController.isSourceTypeAvailable(.camera) { + Button(Strings.addMenuTakePhoto, systemImage: "camera") { + activePicker = .takePhoto + } + Button(Strings.addMenuTakeVideo, systemImage: "video") { + activePicker = .takeVideo + } + } + Button(Strings.addMenuChooseFile, systemImage: "folder") { + isImportingFile = true + } + if !externalPickerOptions.isEmpty { + Section { + ForEach(externalPickerOptions) { option in + Button(option.label, systemImage: option.systemImage) { + activePicker = .external(id: option.id) + } + } + // TODO: AINFRA-1496 — when the server-side numeric size field + // lands, add a "View Usage" item here that opens + // MediaStorageDetailsView (V1 view, kept alive in the app target). + } + } + } label: { + Image(systemName: "plus") + .accessibilityLabel(Strings.addMenuTitle) + } + } + } + @ViewBuilder private func filterButton(for kind: MediaKind?) -> some View { let title = kind?.title ?? Strings.filterAll let isSelected = kind == viewModel.kind diff --git a/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryViewModel.swift b/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryViewModel.swift index f6103ec8333c..67d0e30aa2dc 100644 --- a/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryViewModel.swift +++ b/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryViewModel.swift @@ -16,6 +16,29 @@ final class MediaLibraryViewModel: ObservableObject { private let tracker: any MediaTracker private let client: WordPressClient private let collection: Collection + let uploader: MediaUploader? + + @Published private(set) var bannerSummary: BannerSummary? + @Published private(set) var uploadsScreenItems: [UploadRowItem] = [] + + private var uploaderObserverTask: Task? + + struct BannerSummary: Equatable { + let pendingCount: Int + let failedCount: Int + } + + struct UploadRowItem: Identifiable, Equatable { + enum Mode: Equatable { + case uploading(Progress) + case failed(message: String, isRetryable: Bool) + } + let id: UUID + let displayName: String + let kind: MediaKind + let localFileURL: URL? + let mode: Mode + } @Published private(set) var items: [MediaGridItem] = [] /// Stored, derived from `items` + `kind`. Recomputed only in `reload()` and @@ -57,12 +80,15 @@ final class MediaLibraryViewModel: ObservableObject { /// Builds the collection from the wordpress-rs service: the library when /// `search` is nil, a search collection otherwise. `client` is retained so - /// `observe()` can subscribe to the local cache's update stream. + /// `observe()` can subscribe to the local cache's update stream. The + /// `uploader` is wired only for the library instance; search instances + /// leave it nil and never surface the upload banner or queue. init( service: WpService, client: WordPressClient, tracker: any MediaTracker, - search: String? = nil + search: String? = nil, + uploader: MediaUploader? = nil ) { self.tracker = tracker self.client = client @@ -71,6 +97,112 @@ final class MediaLibraryViewModel: ObservableObject { filter: MediaListFilter(search: search, mediaType: nil), perPage: 100 ) + self.uploader = uploader + startUploaderObserver() + } + + /// Subscribes weakly so a navigated-away view model deallocates instead + /// of being kept alive by the stream loop. The publisher replays the + /// current snapshot to the new subscriber before emitting transitions. + private func startUploaderObserver() { + guard let uploader else { return } + let publisher = uploader.statePublisher + uploaderObserverTask = Task { [weak self] in + for await state in publisher.values { + guard !Task.isCancelled else { return } + guard let self else { return } + self.applyUploaderState(state) + } + } + } + + deinit { + uploaderObserverTask?.cancel() + } + + @MainActor + private func applyUploaderState(_ state: UploaderState) { + if state.isEmpty { + bannerSummary = nil + } else { + bannerSummary = BannerSummary( + pendingCount: state.pendingCount, + failedCount: state.failedCount + ) + } + // `state.entries` preserves submission order across pending/failed + // transitions, so the Uploads-screen row stays put when an + // in-flight upload fails (or a failed row is retried). + uploadsScreenItems = state.entries.map { entry in + switch entry { + case .pending(let p): + return UploadRowItem( + id: p.id, + displayName: p.displayName, + kind: p.kind, + localFileURL: p.localFileURL, + mode: .uploading(p.progress) + ) + case .failed(let f): + return UploadRowItem( + id: f.id, + displayName: f.displayName, + kind: f.kind, + localFileURL: f.localFileURL, + mode: .failed(message: f.errorMessage, isRetryable: f.isRetryable) + ) + } + } + } + + func enqueue(sources: [UploadSource]) async { + guard let uploader else { return } + for source in sources { + let resolvedSource = analyticsSourceFor(source: source) + tracker.track(.mediaLibraryAdded(source: resolvedSource, kind: source.estimatedKind)) + } + await uploader.enqueue(sources: sources) + } + + func cancelUpload(_ id: UUID) async { + await uploader?.cancel(id) + } + + func retryUpload(_ id: UUID) async { + guard let uploader else { return } + tracker.track(.mediaLibraryUploadRetried) + await uploader.retry(id) + } + + func removeUpload(_ id: UUID) async { + await uploader?.remove(id) + } + + func cancelAllUploads() async { await uploader?.cancelAllPending() } + + func retryAllUploads() async { + guard let uploader else { return } + let retryable = uploadsScreenItems.contains { row in + if case .failed(_, let isRetryable) = row.mode { return isRetryable } + return false + } + guard retryable else { return } + tracker.track(.mediaLibraryUploadRetried) + await uploader.retryAllFailed() + } + + func removeAllFailedUploads() async { await uploader?.removeAllFailed() } + + private func analyticsSourceFor(source: UploadSource) -> MediaUploadSource { + switch source { + case .photoLibrary: return .photoLibrary + case .cameraImage, .cameraVideo: return .camera + case .file: return .otherApps + case .imagePlayground: return .imagePlayground + case .remoteURL: + // Stock Photos is the only external picker that produces .remoteURL. + return .stockPhotos + } } // MARK: Filter mutator @@ -154,3 +286,29 @@ final class MediaLibraryViewModel: ObservableObject { } } } + +extension MediaLibraryViewModel: ExternalMediaPickerDelegate { + func didPick(remoteMedia: [ExternalRemoteMedia]) { + let sources = remoteMedia.map { media in + UploadSource.remoteURL( + UploadSource.RemoteURL( + url: media.url, + suggestedName: media.suggestedName, + contentType: media.contentType, + caption: media.caption + ) + ) + } + Task { await self.enqueue(sources: sources) } + } + + func didPick(imagePlaygroundFile url: URL, suggestedName: String) { + Task { + await self.enqueue(sources: [.imagePlayground(url, suggestedName: suggestedName)]) + } + } + + func didCancel() { + // No-op today; hook exists for future analytics if needed. + } +} diff --git a/Modules/Sources/WordPressMediaLibrary/Views/PhotosPickerRepresentable.swift b/Modules/Sources/WordPressMediaLibrary/Views/PhotosPickerRepresentable.swift new file mode 100644 index 000000000000..332d9b86b24e --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Views/PhotosPickerRepresentable.swift @@ -0,0 +1,47 @@ +import PhotosUI +import SwiftUI + +struct PhotosPickerRepresentable: UIViewControllerRepresentable { + let onPicked: ([UploadSource]) -> Void + let onCancel: () -> Void + + func makeUIViewController(context: Context) -> PHPickerViewController { + var config = PHPickerConfiguration(photoLibrary: .shared()) + config.filter = .any(of: [.images, .videos]) + config.selectionLimit = 0 + config.preferredAssetRepresentationMode = .current + let controller = PHPickerViewController(configuration: config) + controller.delegate = context.coordinator + return controller + } + + func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {} + + func makeCoordinator() -> Coordinator { Coordinator(parent: self) } + + final class Coordinator: NSObject, PHPickerViewControllerDelegate { + let parent: PhotosPickerRepresentable + + init(parent: PhotosPickerRepresentable) { + self.parent = parent + } + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + guard !results.isEmpty else { + parent.onCancel() + return + } + let sources: [UploadSource] = results.map { result in + let provider = result.itemProvider + let suggested = provider.suggestedName + let hintUTI = + provider.registeredContentTypes(conformingTo: .movie).first + ?? provider.registeredContentTypes(conformingTo: .image).first + ?? .item + return .photoLibrary(itemProvider: provider, suggestedName: suggested, hint: hintUTI) + } + parent.onPicked(sources) + } + } +} diff --git a/Modules/Sources/WordPressMediaLibrary/Views/UploadRow.swift b/Modules/Sources/WordPressMediaLibrary/Views/UploadRow.swift new file mode 100644 index 000000000000..59b5be0106d7 --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Views/UploadRow.swift @@ -0,0 +1,92 @@ +import Combine +import SwiftUI + +struct UploadRow: View { + let item: MediaLibraryViewModel.UploadRowItem + let onCancel: () -> Void + let onRetry: () -> Void + let onRemove: () -> Void + + var body: some View { + HStack(spacing: 12) { + if let fileURL = item.localFileURL { + UploadThumbnailView( + fileURL: fileURL, + fallbackSystemImage: item.kind.systemImageName + ) + } else { + Image(systemName: item.kind.systemImageName) + .font(.title3) + .foregroundStyle(.secondary) + .frame(width: 44, height: 44) + } + + VStack(alignment: .leading, spacing: 4) { + Text(item.displayName) + .font(.subheadline) + .lineLimit(1) + switch item.mode { + case .uploading(let progress): + UploadProgressBar(progress: progress) + case .failed(let message, _): + Text(message) + .font(.caption) + .foregroundStyle(.red) + .lineLimit(2) + } + } + + switch item.mode { + case .uploading: + Button(action: onCancel) { + Image(systemName: "xmark.circle.fill") + .font(.title3) + .foregroundStyle(.tertiary) + } + .buttonStyle(.plain) + case .failed(_, let isRetryable): + HStack(spacing: 16) { + if isRetryable { + Button(action: onRetry) { + Image(systemName: "arrow.clockwise.circle.fill") + .font(.title3) + .foregroundStyle(.tint) + } + .buttonStyle(.plain) + .accessibilityLabel(Strings.uploadActionRetry) + } + Button(action: onRemove) { + Image(systemName: "xmark.circle.fill") + .font(.title3) + .foregroundStyle(.tertiary) + } + .buttonStyle(.plain) + .accessibilityLabel(Strings.uploadActionRemove) + } + } + } + .padding(.vertical, 8) + } +} + +/// Bar-only progress. `ProgressView(_ progress:)` would auto-render two text +/// labels, including "N of 100" (the uploader's internal unit count), so this +/// observes `fractionCompleted` manually and feeds a label-free bar. +private struct UploadProgressBar: View { + let progress: Progress + @State private var fraction: Double + + init(progress: Progress) { + self.progress = progress + _fraction = State(initialValue: progress.fractionCompleted) + } + + var body: some View { + ProgressView(value: fraction) + .progressViewStyle(.linear) + .onReceive( + progress.publisher(for: \.fractionCompleted) + .receive(on: DispatchQueue.main) + ) { fraction = $0 } + } +} diff --git a/Modules/Sources/WordPressMediaLibrary/Views/UploadThumbnailView.swift b/Modules/Sources/WordPressMediaLibrary/Views/UploadThumbnailView.swift new file mode 100644 index 000000000000..b70bfa6bf72c --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Views/UploadThumbnailView.swift @@ -0,0 +1,49 @@ +import QuickLookThumbnailing +import SwiftUI + +/// QuickLook thumbnail for a local upload file. Falls back to the kind's +/// SF Symbol until generation finishes, or permanently when QuickLook +/// cannot preview the type (e.g. audio). Requesting only `.thumbnail` +/// (never `.icon`) keeps that failure clean instead of yielding a generic +/// system file icon. No caching: scroll-back regenerates, which is cheap +/// for local files. +struct UploadThumbnailView: View { + let fileURL: URL + let fallbackSystemImage: String + + @Environment(\.displayScale) private var displayScale + @State private var thumbnail: UIImage? + + var body: some View { + ZStack { + if let thumbnail { + Image(uiImage: thumbnail) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + Image(systemName: fallbackSystemImage) + .font(.title3) + .foregroundStyle(.secondary) + } + } + .frame(width: 44, height: 44) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .task(id: fileURL) { + let request = QLThumbnailGenerator.Request( + fileAt: fileURL, + size: CGSize(width: 44, height: 44), + scale: displayScale, + representationTypes: .thumbnail + ) + // Extract UIImage inside the completion handler so that only the + // Sendable UIImage crosses the concurrency boundary, not the + // non-Sendable QLThumbnailRepresentation. + let image: UIImage? = await withCheckedContinuation { continuation in + QLThumbnailGenerator.shared.generateBestRepresentation(for: request) { representation, _ in + continuation.resume(returning: representation?.uiImage) + } + } + thumbnail = image + } + } +} diff --git a/Modules/Sources/WordPressMediaLibrary/Views/UploadsView.swift b/Modules/Sources/WordPressMediaLibrary/Views/UploadsView.swift new file mode 100644 index 000000000000..74d4e203a984 --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Views/UploadsView.swift @@ -0,0 +1,91 @@ +import SwiftUI + +struct UploadsView: View { + @ObservedObject var viewModel: MediaLibraryViewModel + @State private var isConfirmingCancelAll = false + + var body: some View { + Group { + if viewModel.uploadsScreenItems.isEmpty { + ContentUnavailableView { + Label(Strings.uploadsScreenEmpty, systemImage: "tray") + } + } else { + List(viewModel.uploadsScreenItems) { item in + UploadRow( + item: item, + onCancel: { Task { await viewModel.cancelUpload(item.id) } }, + onRetry: { Task { await viewModel.retryUpload(item.id) } }, + onRemove: { Task { await viewModel.removeUpload(item.id) } } + ) + } + .listStyle(.plain) + } + } + .navigationTitle(Strings.uploadsScreenTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + bulkMenu + } + } + .alert(Strings.uploadBulkCancelAll, isPresented: $isConfirmingCancelAll) { + Button(Strings.uploadBulkCancelAllConfirm, role: .destructive) { + Task { await viewModel.cancelAllUploads() } + } + Button(Strings.keepUploading, role: .cancel) {} + } message: { + Text(Strings.uploadBulkCancelAllMessage) + } + } + + @ViewBuilder private var bulkMenu: some View { + if hasAnyBulkAction { + Menu { + if hasRetryableFailed { + Button { + Task { await viewModel.retryAllUploads() } + } label: { + Label(Strings.uploadBulkRetryAll, systemImage: "arrow.clockwise") + } + } + if hasFailed { + Button(role: .destructive) { + Task { await viewModel.removeAllFailedUploads() } + } label: { + Label(Strings.uploadBulkRemoveAll, systemImage: "trash") + } + } + if hasUploading { + Button(role: .destructive) { + isConfirmingCancelAll = true + } label: { + Label(Strings.uploadBulkCancelAll, systemImage: "xmark.circle") + } + } + } label: { + Image(systemName: "ellipsis") + } + } + } + + private var hasUploading: Bool { + viewModel.uploadsScreenItems.contains { row in + if case .uploading = row.mode { return true } + return false + } + } + private var hasFailed: Bool { + viewModel.uploadsScreenItems.contains { row in + if case .failed = row.mode { return true } + return false + } + } + private var hasRetryableFailed: Bool { + viewModel.uploadsScreenItems.contains { row in + if case .failed(_, let isRetryable) = row.mode { return isRetryable } + return false + } + } + private var hasAnyBulkAction: Bool { hasUploading || hasFailed } +} diff --git a/WordPress/Classes/ViewRelated/Media/MediaLibraryRouting.swift b/WordPress/Classes/ViewRelated/Media/MediaLibraryRouting.swift index fe5e81cad7aa..7f4a8b10c96f 100644 --- a/WordPress/Classes/ViewRelated/Media/MediaLibraryRouting.swift +++ b/WordPress/Classes/ViewRelated/Media/MediaLibraryRouting.swift @@ -29,6 +29,18 @@ enum MediaLibraryRouting { properties["is_v2"] = "1" let tracker = MediaTrackerAdapter(blog: blog, baseProperties: properties) - return MediaLibraryHostingController.make(client: client, tracker: tracker) + let uploader: MediaUploader + do { + uploader = try MediaUploaderRegistry.shared.uploader(for: blog) + } catch { + Loggers.app.error("Failed to vend uploader: \(error)") + return nil + } + + return MediaLibraryHostingController.make( + client: client, + tracker: tracker, + uploader: uploader + ) } }