From 123b555ab0e92f9062b94c087f58825255ea8f5a Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 14 May 2026 00:13:35 +1200 Subject: [PATCH 01/13] Add WordPressMediaLibrary module skeleton New SPM module under Modules/Sources/WordPressMediaLibrary, registered as a library product, target, and keystone dependency. Initial content is the module-local swift-log Logger and the localized strings table; subsequent commits add the analytics tracker, view model, and SwiftUI views. --- Modules/Package.swift | 16 ++++++++++- .../Logging/Loggers.swift | 5 ++++ .../Strings/Strings.swift | 27 +++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 Modules/Sources/WordPressMediaLibrary/Logging/Loggers.swift create mode 100644 Modules/Sources/WordPressMediaLibrary/Strings/Strings.swift 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/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/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" + ) +} From 6c0e066b4aa8e2c3c3396f3979838b59e7648797 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 14 May 2026 00:16:57 +1200 Subject: [PATCH 02/13] Add MediaTracker analytics protocol to WordPressMediaLibrary @MainActor protocol with one event for M1 (mediaLibraryOpened). MainActor isolation rather than Sendable because the app adapter will store Blog and a properties dict, neither of which conforms to Sendable. Includes MockMediaTracker for previews and tests. --- .../Analytics/MediaTracker.swift | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 Modules/Sources/WordPressMediaLibrary/Analytics/MediaTracker.swift 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 + } +} From 26854afa2be7f655440efe1088f6ce6568238b2d Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 14 May 2026 00:18:41 +1200 Subject: [PATCH 03/13] Add MediaListItem display model Wraps MediaMetadataCollectionItem and translates the per-item state from the wp-rs collection into a view-friendly enum (loaded with up-to-date flag, loading, error). Title falls back from raw -> slug -> nil; thumbnail uses sourceUrl directly for M1 (M2 will pick a smaller size from media details for grid rendering). --- .../Models/MediaListItem.swift | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 Modules/Sources/WordPressMediaLibrary/Models/MediaListItem.swift 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) + } +} From 0cc404ef531a7fc42bbbcddd82235b897e7e2eb6 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 14 May 2026 00:21:52 +1200 Subject: [PATCH 04/13] Add MediaLibraryViewModel @MainActor ObservableObject mirroring the slimmer parts of CustomPostListViewModel. Lazy collection construction cached as a Task to serialize concurrent callers across the actor hop. refresh() reloads items directly after collection.refresh() succeeds, making the cold-cache first load deterministic regardless of .task ordering; handleDataChanges() handles subsequent updates only. loadNextPage() carries a TODO that pagination is M1-temporary. --- .../Views/MediaLibraryViewModel.swift | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryViewModel.swift diff --git a/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryViewModel.swift b/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryViewModel.swift new file mode 100644 index 000000000000..be3d14ec89ec --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryViewModel.swift @@ -0,0 +1,210 @@ +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 + } + + let batches = await client.cache.databaseUpdatesPublisher() + .filter { [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.swift:273-283. + 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.swift:726-735. +// `isSyncing` is NOT part of the wordpress-rs ListInfo surface (which +// exposes only state, currentPage, totalPages, totalItems, perPage at +// wp_mobile.swift:5595-5607); this extension fills the gap. +private extension ListInfo { + var isSyncing: Bool { + state == .fetchingFirstPage || state == .fetchingNextPage + } +} + +private extension MediaMetadataCollectionWithEditContext { + var isSyncing: Bool { listInfo()?.isSyncing == true } +} From 15344cf4e9f3d0ffe3ccfda6f5630eb0d561837f Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 14 May 2026 00:24:28 +1200 Subject: [PATCH 05/13] Add MediaLibraryView and MediaLibraryRow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SwiftUI List renders each item as a 44x44 CachedAsyncImage thumbnail plus filename. Per-item state drives opacity (stale rows at 70%) and the error icon (exclamationmark.triangle in place of the thumbnail when the per-item state is .error). View has two .task modifiers — observer for subsequent updates, refresh task for the deterministic initial load. Single overlay with explicit precedence (error -> empty -> loading) prevents stacking. --- .../Views/MediaLibraryRow.swift | 56 ++++++++++++++++ .../Views/MediaLibraryView.swift | 64 +++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryRow.swift create mode 100644 Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryView.swift 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() + } +} From a81cf01687bf5b73fbecf7c76c3fae779dd960d1 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 14 May 2026 00:30:29 +1200 Subject: [PATCH 06/13] Add MediaLibraryHostingController public factory Single module entry point that accepts only module-reachable types (WordPressClient, MediaTracker) and returns a UIHostingController. Keeps Blog and WordPressClientFactory out of the module so the dependency graph stays clean. --- .../Views/MediaLibraryHostingController.swift | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryHostingController.swift 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 + } +} From e3894935b0a51fe3f8b26ef6b3eaef859247e570 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 14 May 2026 00:34:01 +1200 Subject: [PATCH 07/13] Add FeatureFlag.mediaLibraryV2 and MediaTrackerAdapter New flag defaults true on debug builds (matches socialSharingV2 / customPostTypes pattern). MediaTrackerAdapter bridges the WordPressMediaLibrary tracker protocol to WPAppAnalytics, preserving V1 tap_source/tab_source fidelity and adding an is_v2 discriminator for the M7 parity audit. --- .../Analytics/MediaTrackerAdapter.swift | 22 +++++++++++++++++++ .../BuildInformation/FeatureFlag.swift | 4 ++++ 2 files changed, 26 insertions(+) create mode 100644 WordPress/Classes/Utility/Analytics/MediaTrackerAdapter.swift 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" } } } From 3556ed51de5aeb935f91315b1ef7bc753c67f55a Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 14 May 2026 00:36:31 +1200 Subject: [PATCH 08/13] Add MediaLibraryRouting helper Shared routing predicate for the two V1 Media Library entry points. Owns the FeatureFlag check, the WordPressSite capability gate, the WordPressClient construction, and the MediaTrackerAdapter wiring. Returns nil on capability miss so each call site's V1 fall-through is a one-liner. --- .../Media/MediaLibraryRouting.swift | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 WordPress/Classes/ViewRelated/Media/MediaLibraryRouting.swift 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) + } +} From 8a5ec97b56ad41c50456844a42899cd9ab6a7ba8 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 14 May 2026 00:38:28 +1200 Subject: [PATCH 09/13] Route Blog Details Media menu to V2 when the flag is on When FeatureFlag.mediaLibraryV2 is enabled and the site supports core REST, present the V2 hosting controller instead of SiteMediaViewController. The showPicker == true path always uses V1 (SiteMediaPickerViewController is out of master-task scope). On the V2 path, the existing trackEvent call is skipped so the analytics fire happens once via MediaTrackerAdapter. --- .../BlogDetailsViewController+Swift.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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) From c46412ad006d9854dc79019bd4e3ca3b484b8bfd Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 14 May 2026 00:38:51 +1200 Subject: [PATCH 10/13] Format DashboardQuickActionsCardCell.swift before media routing edit --- .../DashboardQuickActionsCardCell.swift | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) 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..3deddfe275d0 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 From 187d75df7859e7784fd34a441ac62f9afe65c819 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 14 May 2026 00:40:32 +1200 Subject: [PATCH 11/13] Route Dashboard quick-action Media tile to V2 when the flag is on When FeatureFlag.mediaLibraryV2 is enabled and the site supports core REST, present the V2 hosting controller instead of SiteMediaViewController. On the V2 path, the existing trackQuickActionsEvent call is skipped so the analytics fire happens once via MediaTrackerAdapter with the dashboard tap_source/tab_source values plus is_v2 = 1. --- .../Quick Actions/DashboardQuickActionsCardCell.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 3deddfe275d0..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 @@ -108,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) From 41efc233f3921a458fe312aab075780fe2b1a55b Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 14 May 2026 08:52:29 +1200 Subject: [PATCH 12/13] Mark MediaLibraryViewModel cache observer's filter closure as @Sendable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wp-rs's `databaseUpdatesPublisher()` emits NotificationCenter posts from the SQLite worker thread. Under Swift 6, the filter closure was inferred @MainActor (inherited from the enclosing @MainActor ViewModel) and tripped _swift_task_checkIsolatedSwift when invoked from the SQLite thread. Marking the closure @Sendable opts out of that inheritance so the cheap isRelevantUpdate check stays on the background thread; the downstream .collect(.byTime(DispatchQueue.main, …)) keeps delivering batches on main where the @Published mutations run. --- .../Views/MediaLibraryViewModel.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryViewModel.swift b/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryViewModel.swift index be3d14ec89ec..62369ee8ca02 100644 --- a/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryViewModel.swift +++ b/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryViewModel.swift @@ -116,8 +116,18 @@ final class MediaLibraryViewModel: ObservableObject { 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 { [weak collection] in collection?.isRelevantUpdate(hook: $0) == true } + .filter { @Sendable [weak collection] in collection?.isRelevantUpdate(hook: $0) == true } .collect(.byTime(DispatchQueue.main, .milliseconds(50))) .values From 2f83f1669d2cc6cb770cdf6c0baba0f49e55589e Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 14 May 2026 18:16:29 +1200 Subject: [PATCH 13/13] Update code comments --- .../Views/MediaLibraryViewModel.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryViewModel.swift b/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryViewModel.swift index 62369ee8ca02..09b808dd5bec 100644 --- a/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryViewModel.swift +++ b/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryViewModel.swift @@ -149,7 +149,7 @@ final class MediaLibraryViewModel: ObservableObject { // in quick succession before the collection's listInfo // reflects the sync, producing duplicate fetches and noisy // StaleLoadMore errors. Mirrors the isLoadingMore guard in - // CustomPostListView.swift:273-283. + // CustomPostListView. guard !isRefreshing, !isLoadingNextPage else { return } isLoadingNextPage = true defer { isLoadingNextPage = false } @@ -205,10 +205,10 @@ final class MediaLibraryViewModel: ObservableObject { } } -// Mirrors the private extension in CustomPostListViewModel.swift:726-735. +// Mirrors the private extension in CustomPostListViewModel. // `isSyncing` is NOT part of the wordpress-rs ListInfo surface (which -// exposes only state, currentPage, totalPages, totalItems, perPage at -// wp_mobile.swift:5595-5607); this extension fills the gap. +// exposes only state, currentPage, totalPages, totalItems, and perPage); +// this extension fills the gap. private extension ListInfo { var isSyncing: Bool { state == .fetchingFirstPage || state == .fetchingNextPage