diff --git a/Modules/Package.swift b/Modules/Package.swift index db3e7c80bdfe..510471c51593 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -25,7 +25,8 @@ let package = Package( .library(name: "WordPressReader", targets: ["WordPressReader"]), .library(name: "WordPressCore", targets: ["WordPressCore"]), .library(name: "WordPressCoreProtocols", targets: ["WordPressCoreProtocols"]), - .library(name: "WordPressKit", targets: ["WordPressKit"]) + .library(name: "WordPressKit", targets: ["WordPressKit"]), + .library(name: "WordPressMediaLibrary", targets: ["WordPressMediaLibrary"]) ], dependencies: [ .package(url: "https://github.com/airbnb/lottie-ios", from: "4.4.0"), @@ -133,6 +134,18 @@ let package = Package( ], resources: [.process("Resources")] ), + .target( + name: "WordPressMediaLibrary", + dependencies: [ + "AsyncImageKit", + "DesignSystem", + "WordPressShared", + "WordPressUI", + "WordPressCore", + .product(name: "WordPressAPI", package: "wordpress-rs"), + .product(name: "Logging", package: "swift-log") + ] + ), .target( name: "ShareExtensionCore", dependencies: [ @@ -435,6 +448,7 @@ enum XcodeSupport { "WordPressIntelligence", "WordPressShared", "WordPressLegacy", + "WordPressMediaLibrary", "WordPressReader", "WordPressUI", "WordPressCore", diff --git a/Modules/Sources/WordPressMediaLibrary/Analytics/MediaTracker.swift b/Modules/Sources/WordPressMediaLibrary/Analytics/MediaTracker.swift new file mode 100644 index 000000000000..0f1e53a4cbe8 --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Analytics/MediaTracker.swift @@ -0,0 +1,29 @@ +import Foundation + +/// Analytics protocol for the Media Library module. +/// +/// `@MainActor` rather than `Sendable` because the app-target adapter stores +/// `Blog` (an `NSManagedObject`, not Sendable) and a properties dictionary +/// containing `Any`. The open event always fires from a MainActor context +/// (`MediaLibraryView.task`), so MainActor isolation is the right shape. +@MainActor +public protocol MediaTracker { + func track(_ event: MediaTrackerEvent) +} + +public enum MediaTrackerEvent: Sendable { + case mediaLibraryOpened + // M2-M7 add more cases here. +} + +/// No-op tracker for previews and module-internal default-construction. +@MainActor +public struct MockMediaTracker: MediaTracker { + public init() {} + + public func track(_ event: MediaTrackerEvent) { + #if DEBUG + debugPrint("[MediaTracker] \(event)") + #endif + } +} diff --git a/Modules/Sources/WordPressMediaLibrary/Logging/Loggers.swift b/Modules/Sources/WordPressMediaLibrary/Logging/Loggers.swift new file mode 100644 index 000000000000..b6144faa20b1 --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Logging/Loggers.swift @@ -0,0 +1,5 @@ +import Logging + +enum Loggers { + static let mediaLibrary = Logger(label: "org.wordpress.media-library") +} diff --git a/Modules/Sources/WordPressMediaLibrary/Models/MediaListItem.swift b/Modules/Sources/WordPressMediaLibrary/Models/MediaListItem.swift new file mode 100644 index 000000000000..187b8a330b8f --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Models/MediaListItem.swift @@ -0,0 +1,67 @@ +import Foundation +import WordPressAPI +import WordPressAPIInternal + +struct MediaListItem: Identifiable, Equatable { + let id: Int64 + let title: String? + let thumbnailURL: URL? + let state: State + + enum State: Equatable { + case loaded(isUpToDate: Bool) + case loading + case error(message: String) + } + + init(item: MediaMetadataCollectionItem) { + self.id = item.id + + switch item.state { + case .fresh(let entity): + self.title = MediaListItem.makeTitle(from: entity.data) + self.thumbnailURL = MediaListItem.makeThumbnailURL(from: entity.data) + self.state = .loaded(isUpToDate: true) + + case .stale(let entity): + self.title = MediaListItem.makeTitle(from: entity.data) + self.thumbnailURL = MediaListItem.makeThumbnailURL(from: entity.data) + self.state = .loaded(isUpToDate: false) + + case .fetchingWithData(let entity): + self.title = MediaListItem.makeTitle(from: entity.data) + self.thumbnailURL = MediaListItem.makeThumbnailURL(from: entity.data) + self.state = .loading + + case .fetching, .missing: + self.title = nil + self.thumbnailURL = nil + self.state = .loading + + case .failed(let error): + self.title = nil + self.thumbnailURL = nil + self.state = .error(message: error) + + case .failedWithData(let error, let entity): + self.title = MediaListItem.makeTitle(from: entity.data) + self.thumbnailURL = MediaListItem.makeThumbnailURL(from: entity.data) + self.state = .error(message: error) + } + } + + /// Prefer `title.raw`, fall back to `slug`, fall back to nil. The view + /// renders `Strings.untitled` when this is nil. + private static func makeTitle(from media: MediaWithEditContext) -> String? { + let raw = (media.title.raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if !raw.isEmpty { return raw } + let slug = media.slug.trimmingCharacters(in: .whitespacesAndNewlines) + return slug.isEmpty ? nil : slug + } + + /// M1 uses `sourceUrl` as the thumbnail. M2 picks a smaller size from + /// `media.mediaDetails.sizes` for grid rendering. + private static func makeThumbnailURL(from media: MediaWithEditContext) -> URL? { + URL(string: media.sourceUrl) + } +} diff --git a/Modules/Sources/WordPressMediaLibrary/Strings/Strings.swift b/Modules/Sources/WordPressMediaLibrary/Strings/Strings.swift new file mode 100644 index 000000000000..8cff5f5862fa --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Strings/Strings.swift @@ -0,0 +1,27 @@ +import Foundation + +enum Strings { + static let title = NSLocalizedString( + "mediaLibrary.screen.title", + value: "Media", + comment: "Title for the Media Library V2 screen" + ) + + static let empty = NSLocalizedString( + "mediaLibrary.empty.message", + value: "No media yet", + comment: "Message shown when the Media Library has no items" + ) + + static let errorRetry = NSLocalizedString( + "mediaLibrary.error.retry", + value: "Try again", + comment: "Button label to retry loading after an error" + ) + + static let untitled = NSLocalizedString( + "mediaLibrary.row.untitled", + value: "(no title)", + comment: "Placeholder shown for media items with no title" + ) +} diff --git a/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryHostingController.swift b/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryHostingController.swift new file mode 100644 index 000000000000..4a2cc64a72c1 --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryHostingController.swift @@ -0,0 +1,21 @@ +import SwiftUI +import UIKit +import WordPressCore + +public enum MediaLibraryHostingController { + /// Module-side factory. Constructs the ViewModel from a resolved + /// WordPressClient and wraps it in a UIHostingController. The Blog gate + /// and WordPressClient construction live in the app target — see + /// `WordPress/Classes/ViewRelated/Media/MediaLibraryRouting.swift`. + @MainActor + public static func make( + client: WordPressClient, + tracker: any MediaTracker + ) -> UIViewController { + let viewModel = MediaLibraryViewModel(client: client, tracker: tracker) + let view = MediaLibraryView(viewModel: viewModel, tracker: tracker) + let host = UIHostingController(rootView: view) + host.navigationItem.largeTitleDisplayMode = .never + return host + } +} diff --git a/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryRow.swift b/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryRow.swift new file mode 100644 index 000000000000..deafbf604c4e --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryRow.swift @@ -0,0 +1,56 @@ +import SwiftUI +import AsyncImageKit + +struct MediaLibraryRow: View { + let item: MediaListItem + + var body: some View { + HStack(spacing: 12) { + thumbnail + .frame(width: 44, height: 44) + .clipShape(RoundedRectangle(cornerRadius: 6)) + Text(displayTitle) + .font(.body) + .lineLimit(1) + Spacer() + } + .opacity(opacityForState) + .accessibilityLabel(displayTitle) + } + + @ViewBuilder + private var thumbnail: some View { + switch item.state { + case .error: + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(uiColor: .secondarySystemBackground)) + case .loading, .loaded: + // Use the closure-form initializer so we can call + // `.resizable()` on the inner image — the default + // `CachedAsyncImage(url:)` returns a non-resizable Image (or a + // Color), which would render at the asset's natural size and + // ignore the .frame(width: 44, height: 44) we apply outside. + // Matches the existing pattern in JetpackStats/Views/AvatarView.swift. + CachedAsyncImage(url: item.thumbnailURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + Color(uiColor: .secondarySystemBackground) + } + } + } + + private var displayTitle: String { + item.title ?? Strings.untitled + } + + private var opacityForState: Double { + if case .loaded(let isUpToDate) = item.state, !isUpToDate { + return 0.7 + } + return 1.0 + } +} diff --git a/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryView.swift b/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryView.swift new file mode 100644 index 000000000000..e66f974472e9 --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryView.swift @@ -0,0 +1,64 @@ +import SwiftUI + +struct MediaLibraryView: View { + @ObservedObject var viewModel: MediaLibraryViewModel + let tracker: any MediaTracker + + var body: some View { + List(viewModel.items) { item in + MediaLibraryRow(item: item) + .onAppear { + Task { await viewModel.loadNextPageIfNeeded(after: item) } + } + } + .refreshable { await viewModel.pullToRefresh() } + // Two .task modifiers run concurrently from view appearance. Refresh + // is now deterministic on its own (calls loadItems directly after + // network success), so the observer task is purely for subsequent + // updates (browser-side edits etc.). + .task { await viewModel.handleDataChanges() } + .task { + tracker.track(.mediaLibraryOpened) + // performInitialLoad() owns isRefreshing across the entire + // loadCachedItems + refresh sequence so the empty state can't + // flash between them on cold-cache first open. + await viewModel.performInitialLoad() + } + .navigationTitle(Strings.title) + // Single overlay with explicit precedence — three separate overlays + // could stack (e.g., empty + error both true after a failed cold- + // cache refresh). Error wins, then empty, then loading. + .overlay { + if let error = viewModel.errorToDisplay() { + errorView(error) + } else if viewModel.shouldDisplayEmptyView { + emptyView + } else if viewModel.shouldDisplayInitialLoading { + ProgressView() + } + } + } + + private var emptyView: some View { + ContentUnavailableView( + Strings.empty, + systemImage: "photo.on.rectangle" + ) + } + + private func errorView(_ error: Error) -> some View { + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .font(.largeTitle) + .foregroundStyle(.secondary) + Text(error.localizedDescription) + .multilineTextAlignment(.center) + .padding(.horizontal) + Button(Strings.errorRetry) { + Task { await viewModel.refresh() } + } + .buttonStyle(.borderedProminent) + } + .padding() + } +} diff --git a/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryViewModel.swift b/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryViewModel.swift new file mode 100644 index 000000000000..09b808dd5bec --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryViewModel.swift @@ -0,0 +1,220 @@ +import Foundation +import SwiftUI +import WordPressAPI +import WordPressAPIInternal +import WordPressCore + +@MainActor +final class MediaLibraryViewModel: ObservableObject { + private let client: WordPressClient + private let tracker: any MediaTracker + private var collectionTask: Task? + private var isLoadingNextPage = false + + @Published private(set) var items: [MediaListItem] = [] + @Published private(set) var listInfo: ListInfo? + @Published private(set) var error: Error? + /// Set to true while `refresh()` is in flight. Drives the cold-cache + /// initial-loading spinner explicitly, instead of inferring from the + /// wp-rs collection's `listInfo.isSyncing` (which only updates after + /// the cache observer wakes — racy). + @Published private(set) var isRefreshing = false + + var shouldDisplayInitialLoading: Bool { items.isEmpty && isRefreshing } + var shouldDisplayEmptyView: Bool { items.isEmpty && !isRefreshing && error == nil } + func errorToDisplay() -> Error? { items.isEmpty ? error : nil } + + init(client: WordPressClient, tracker: any MediaTracker) { + self.client = client + self.tracker = tracker + } + + /// Resolves WpService and constructs the collection, exactly once. + /// Cached as a Task so concurrent callers (the cache observer task and + /// the refresh task both wake at view appearance) await the same + /// construction. Same pattern WordPressClient itself uses for site + /// info / current user. + private func resolveCollection() async throws -> MediaMetadataCollectionWithEditContext { + if let collectionTask { + return try await collectionTask.value + } + let task = Task { [client] in + let service = try await client.service + return service.media() + .createMediaMetadataCollectionWithEditContext( + filter: MediaListFilter(), + perPage: 100 + ) + } + self.collectionTask = task + return try await task.value + } + + /// Loads cached items into `items` immediately so the first paint isn't + /// blocked on the network round-trip. + func loadCachedItems() async { + do { + let collection = try await resolveCollection() + await loadItems(from: collection) + } catch { + Loggers.mediaLibrary.error("Failed to load cached items: \(error)") + } + } + + /// Single entry point for the SwiftUI .task block. Owns isRefreshing + /// across BOTH loadCachedItems() and refresh() so the empty state can't + /// flash in the microsecond window between them on cold-cache first + /// open. SwiftUI re-evaluates body on every @Published change, so even + /// a single MainActor scheduling hop where (items.isEmpty && + /// !isRefreshing && error == nil) holds will paint the empty view. + func performInitialLoad() async { + isRefreshing = true + defer { isRefreshing = false } + await loadCachedItems() + await refresh() + } + + func refresh() async { + // isRefreshing drives the cold-cache initial-loading UI deterministically, + // independent of the wp-rs cache observer's wake timing. + isRefreshing = true + defer { isRefreshing = false } + + // Clear stale error from any previous failed attempt so a successful + // retry unblocks the empty/list UI even when the new fetch returns + // zero items. Without this, errorToDisplay() would keep showing the + // old error because items.isEmpty stays true. + self.error = nil + + do { + let collection = try await resolveCollection() + _ = try await collection.refresh() + // Reload items directly after refresh succeeds, instead of + // relying on the cache observer to wake up. SwiftUI doesn't + // order sibling .task modifiers, so handleDataChanges() may not + // have subscribed before refresh wrote to the cache. This makes + // the cold-cache first load deterministic; handleDataChanges() + // handles subsequent updates only. + await loadItems(from: collection) + } catch { + Loggers.mediaLibrary.error("Media library refresh failed: \(error)") + show(error: error) + } + } + + func pullToRefresh() async { await refresh() } + + /// Long-running cache observer for SUBSEQUENT updates only — the initial + /// load is handled deterministically by `refresh()` calling + /// `loadItems(from:)` directly. + func handleDataChanges() async { + let collection: MediaMetadataCollectionWithEditContext + do { + collection = try await resolveCollection() + } catch { + // Collection couldn't be constructed; nothing to observe. + return + } + + // The filter closure runs synchronously on whichever thread the + // upstream publisher emits on — for wp-rs's `databaseUpdatesPublisher()` + // that's the SQLite worker thread (NotificationCenter post from the + // rusqlite update hook). Marking it `@Sendable` opts out of the + // implicit `@MainActor` isolation that Swift 6 would otherwise + // inherit from the enclosing class, so the cheap `isRelevantUpdate` + // check stays on the background thread without tripping the runtime + // MainActor assertion. The downstream `.collect(.byTime(DispatchQueue.main, …))` + // hops to main before delivering batches, so the `for await` body + // runs on main where it mutates `@Published` state. + let batches = await client.cache.databaseUpdatesPublisher() + .filter { @Sendable [weak collection] in collection?.isRelevantUpdate(hook: $0) == true } + .collect(.byTime(DispatchQueue.main, .milliseconds(50))) + .values + + for await _ in batches { + await loadItems(from: collection) + } + } + + // TODO: Pagination is temporary for M1. Future milestones will switch + // the Media Library to a full-library sync model rather than per-page + // fetches. + func loadNextPage() async throws { + // Two guards: + // 1. !isRefreshing — warm-cache first open paints cached rows + // while the initial refresh is still in flight; trailing-row + // .onAppear can fire loadNextPage concurrently with that + // refresh. Defer pagination until the initial load completes. + // 2. !isLoadingNextPage — trailing-N rows can each fire .onAppear + // in quick succession before the collection's listInfo + // reflects the sync, producing duplicate fetches and noisy + // StaleLoadMore errors. Mirrors the isLoadingMore guard in + // CustomPostListView. + guard !isRefreshing, !isLoadingNextPage else { return } + isLoadingNextPage = true + defer { isLoadingNextPage = false } + + let collection = try await resolveCollection() + guard !collection.isSyncing, + collection.hasMorePages() ?? true + else { return } + if collection.listInfo()?.currentPage == nil { + _ = try await collection.refresh() + } else { + _ = try await collection.loadNextPage() + } + await loadItems(from: collection) + } + + // TODO: Pagination is temporary for M1 — see loadNextPage(). + func loadNextPageIfNeeded(after item: MediaListItem) async { + // Trigger a fetch only when the row that just appeared is one of the + // last few rows we have loaded — protects against firing for every + // single .onAppear above the fold. + let trailingThreshold = 10 + guard items.suffix(trailingThreshold).contains(where: { $0.id == item.id }) else { + return + } + do { + try await loadNextPage() + } catch { + Loggers.mediaLibrary.error("Media library loadNextPage failed: \(error)") + show(error: error) + } + } + + /// Reads the current snapshot from the collection and updates @Published + /// state. Shared by `loadCachedItems()`, `refresh()`, `loadNextPage()`, + /// and `handleDataChanges()` so all reload paths funnel through the + /// same code. + private func loadItems(from collection: MediaMetadataCollectionWithEditContext) async { + do { + self.listInfo = collection.listInfo() + let metadataItems = try await collection.loadItems() + withAnimation { + self.items = metadataItems.map(MediaListItem.init(item:)) + } + } catch { + Loggers.mediaLibrary.error("Failed to load items: \(error)") + } + } + + private func show(error: Error) { + if case FetchError.StaleLoadMore = error { return } + self.error = error + } +} + +// Mirrors the private extension in CustomPostListViewModel. +// `isSyncing` is NOT part of the wordpress-rs ListInfo surface (which +// exposes only state, currentPage, totalPages, totalItems, and perPage); +// this extension fills the gap. +private extension ListInfo { + var isSyncing: Bool { + state == .fetchingFirstPage || state == .fetchingNextPage + } +} + +private extension MediaMetadataCollectionWithEditContext { + var isSyncing: Bool { listInfo()?.isSyncing == true } +} diff --git a/WordPress/Classes/Utility/Analytics/MediaTrackerAdapter.swift b/WordPress/Classes/Utility/Analytics/MediaTrackerAdapter.swift new file mode 100644 index 000000000000..ce702230a467 --- /dev/null +++ b/WordPress/Classes/Utility/Analytics/MediaTrackerAdapter.swift @@ -0,0 +1,22 @@ +import Foundation +import WordPressData +import WordPressMediaLibrary +import WordPressShared + +/// App-target adapter that bridges the module's `MediaTracker` to +/// `WPAppAnalytics` while preserving the V1 analytics property fidelity +/// (tap_source, tab_source) and adding an `is_v2: "1"` discriminator. +@MainActor +struct MediaTrackerAdapter: MediaTracker { + let blog: Blog + let baseProperties: [String: Any] + + func track(_ event: MediaTrackerEvent) { + let stat: WPAnalyticsStat + switch event { + case .mediaLibraryOpened: + stat = .openedMediaLibrary + } + WPAppAnalytics.track(stat, properties: baseProperties, blog: blog) + } +} diff --git a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift index e55c8328a1e9..4c4670af9ca9 100644 --- a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift +++ b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift @@ -30,6 +30,7 @@ public enum FeatureFlag: Int, CaseIterable { case customPostTypes case cptPostsAndPages case socialSharingV2 + case mediaLibraryV2 /// Returns a boolean indicating if the feature is enabled. /// @@ -95,6 +96,8 @@ public enum FeatureFlag: Int, CaseIterable { return BuildConfiguration.current == .debug case .socialSharingV2: return BuildConfiguration.current == .debug + case .mediaLibraryV2: + return BuildConfiguration.current == .debug } } @@ -141,6 +144,7 @@ extension FeatureFlag { case .customPostTypes: "Custom Post Types" case .cptPostsAndPages: "Custom Post Types: Posts and Pages" case .socialSharingV2: "Social Sharing v2" + case .mediaLibraryV2: "Media Library v2" } } } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsCardCell.swift index eec5ab2892bb..3e9d2aae7f37 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsCardCell.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsCardCell.swift @@ -54,14 +54,16 @@ final class DashboardQuickActionsCardCell: UICollectionViewCell, Reusable, UITab self?.deselectCurrentCell() } - viewModel.$items.sink { [weak self] in - guard let self else { return } - self.items = $0 - self.tableView.reloadData() - self.setNeedsLayout() - self.layoutIfNeeded() - self.parentViewController?.collectionView.collectionViewLayout.invalidateLayout() - }.store(in: &cancellables) + viewModel.$items + .sink { [weak self] in + guard let self else { return } + self.items = $0 + self.tableView.reloadData() + self.setNeedsLayout() + self.layoutIfNeeded() + self.parentViewController?.collectionView.collectionViewLayout.invalidateLayout() + } + .store(in: &cancellables) } private func deselectCurrentCell() { @@ -77,7 +79,9 @@ final class DashboardQuickActionsCardCell: UICollectionViewCell, Reusable, UITab } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: Constants.cellReuseID, for: indexPath) as! DashboardQuickActionCell + let cell = + tableView.dequeueReusableCell(withIdentifier: Constants.cellReuseID, for: indexPath) + as! DashboardQuickActionCell cell.configure(items[indexPath.row]) cell.backgroundColor = .clear cell.accessoryType = .disclosureIndicator @@ -104,6 +108,16 @@ final class DashboardQuickActionsCardCell: UICollectionViewCell, Reusable, UITab parentViewController.show(viewController, sender: nil) } case .media: + if let v2 = MediaLibraryRouting.makeViewController( + for: blog, + baseAnalyticsProperties: [ + WPAppAnalyticsKeyTapSource: "quick_actions", + WPAppAnalyticsKeyTabSource: "dashboard" + ] + ) { + parentViewController.show(v2, sender: nil) + return + } trackQuickActionsEvent(.openedMediaLibrary, blog: blog) let controller = SiteMediaViewController(blog: blog) parentViewController.show(controller, sender: nil) diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift index fb246e155d5c..de8e10e129c6 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift @@ -151,6 +151,18 @@ extension BlogDetailsViewController { } public func showMediaLibrary(from source: BlogDetailsNavigationSource, showPicker: Bool) { + if !showPicker, + let v2 = MediaLibraryRouting.makeViewController( + for: blog, + baseAnalyticsProperties: [ + WPAppAnalyticsKeyTapSource: source.string, + WPAppAnalyticsKeyTabSource: "site_menu" + ] + ) + { + presentationDelegate?.presentBlogDetailsViewController(v2) + return + } trackEvent(.openedMediaLibrary, from: source) let controller = SiteMediaViewController(blog: blog, showPicker: showPicker) presentationDelegate?.presentBlogDetailsViewController(controller) diff --git a/WordPress/Classes/ViewRelated/Media/MediaLibraryRouting.swift b/WordPress/Classes/ViewRelated/Media/MediaLibraryRouting.swift new file mode 100644 index 000000000000..fe5e81cad7aa --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/MediaLibraryRouting.swift @@ -0,0 +1,34 @@ +import UIKit +import WordPressCore +import WordPressData +import WordPressMediaLibrary + +/// Single source of truth for routing into the V2 Media Library. Both V1 +/// entry points (BlogDetailsViewController.showMediaLibrary and +/// DashboardQuickActionsCardCell .media case) call this helper. Returns nil +/// when either the FeatureFlag is off or the site can't construct a +/// WordPressSite, so the caller's V1 fall-through is a one-liner. +@MainActor +enum MediaLibraryRouting { + static func makeViewController( + for blog: Blog, + baseAnalyticsProperties: [String: Any] + ) -> UIViewController? { + guard FeatureFlag.mediaLibraryV2.enabled, + let site = try? WordPressSite(blog: blog) + else { + return nil + } + let client = WordPressClientFactory.shared.instance(for: site) + + // Explicit two-step instead of `.merging(...)`. Reason: + // `baseAnalyticsProperties` is `[String: Any]`; a `["is_v2": "1"]` + // literal can infer as `[String: String]` and fail to type-check + // against the merging overload. Explicit form avoids the gamble. + var properties = baseAnalyticsProperties + properties["is_v2"] = "1" + let tracker = MediaTrackerAdapter(blog: blog, baseProperties: properties) + + return MediaLibraryHostingController.make(client: client, tracker: tracker) + } +}