Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion Modules/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -435,6 +448,7 @@ enum XcodeSupport {
"WordPressIntelligence",
"WordPressShared",
"WordPressLegacy",
"WordPressMediaLibrary",
"WordPressReader",
"WordPressUI",
"WordPressCore",
Expand Down
29 changes: 29 additions & 0 deletions Modules/Sources/WordPressMediaLibrary/Analytics/MediaTracker.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
5 changes: 5 additions & 0 deletions Modules/Sources/WordPressMediaLibrary/Logging/Loggers.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import Logging

enum Loggers {
static let mediaLibrary = Logger(label: "org.wordpress.media-library")
}
67 changes: 67 additions & 0 deletions Modules/Sources/WordPressMediaLibrary/Models/MediaListItem.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
27 changes: 27 additions & 0 deletions Modules/Sources/WordPressMediaLibrary/Strings/Strings.swift
Original file line number Diff line number Diff line change
@@ -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"
)
}
Original file line number Diff line number Diff line change
@@ -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
}
}
56 changes: 56 additions & 0 deletions Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryRow.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
64 changes: 64 additions & 0 deletions Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryView.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading