-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Add WordPressMediaLibrary module with a basic media list screen #25560
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
crazytonyli
wants to merge
16
commits into
trunk
Choose a base branch
from
task/media-v2-foundation
base: trunk
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
123b555
Add WordPressMediaLibrary module skeleton
crazytonyli 6c0e066
Add MediaTracker analytics protocol to WordPressMediaLibrary
crazytonyli 26854af
Add MediaListItem display model
crazytonyli 0cc404e
Add MediaLibraryViewModel
crazytonyli 15344cf
Add MediaLibraryView and MediaLibraryRow
crazytonyli a81cf01
Add MediaLibraryHostingController public factory
crazytonyli e389493
Add FeatureFlag.mediaLibraryV2 and MediaTrackerAdapter
crazytonyli 3556ed5
Add MediaLibraryRouting helper
crazytonyli 8a5ec97
Route Blog Details Media menu to V2 when the flag is on
crazytonyli c46412a
Format DashboardQuickActionsCardCell.swift before media routing edit
crazytonyli 187d75d
Route Dashboard quick-action Media tile to V2 when the flag is on
crazytonyli 41efc23
Mark MediaLibraryViewModel cache observer's filter closure as @Sendable
crazytonyli 2f83f16
Update code comments
crazytonyli f8a7d7d
Update wordpress-rs
crazytonyli d034b13
Remove references to local tasks
crazytonyli ea92ea3
Drop redundant whitespace trim on media slug
crazytonyli File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
28 changes: 28 additions & 0 deletions
28
Modules/Sources/WordPressMediaLibrary/Analytics/MediaTracker.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| 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 | ||
| } | ||
|
|
||
| /// 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 | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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") | ||
| } |
66 changes: 66 additions & 0 deletions
66
Modules/Sources/WordPressMediaLibrary/Models/MediaListItem.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| 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 } | ||
| return media.slug.isEmpty ? nil : media.slug | ||
| } | ||
|
|
||
| /// Uses `sourceUrl` as the thumbnail for now. A future change will pick 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
27
Modules/Sources/WordPressMediaLibrary/Strings/Strings.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| ) | ||
| } |
21 changes: 21 additions & 0 deletions
21
Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryHostingController.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
56
Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryRow.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
64
Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryView.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This will be updated once 0.3.0 is released.