Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
cc999b4
Register WordPressMediaLibraryTests target
crazytonyli May 14, 2026
ed4dcaa
Add WPAnalyticsStat cases for Media Library V2 filter/search/grid-toggle
crazytonyli May 14, 2026
bd9c6c4
Format TracksMappedEvent.swift before M2 mappings
crazytonyli May 14, 2026
59210bb
Map M2 Media Library stats to Tracks event names
crazytonyli May 14, 2026
fe8e882
Add WordPressMediaLibraryTests to the WordPress test plan
crazytonyli May 14, 2026
cad76df
Add MediaKind enum mapping MediaDetailsPayload to image/video/audio/d…
crazytonyli May 14, 2026
de0c4e9
Add MediaThumbnailURL picker preferring medium size with sourceUrl fa…
crazytonyli May 14, 2026
d0dbf61
Add MediaGridDuration formatter for m:ss and h:mm:ss video durations
crazytonyli May 14, 2026
ba969d7
Add MediaGridFilter as the Hashable identity driving .task(id:) colle…
crazytonyli May 14, 2026
fe32c03
Add AspectRatioPreference helper reusing the V1 UserDefaults key
crazytonyli May 14, 2026
0fc8fff
Add M2 grid strings and MediaKind UI helpers
crazytonyli May 14, 2026
15b8f88
Add MediaGridLayoutMath value type mirroring V1 cell-size math
crazytonyli May 14, 2026
5b258df
Add MediaGridItem display model replacing MediaListItem
crazytonyli May 14, 2026
c8e6937
Add MediaGridCell with per-kind rendering and V1-parity aspect-ratio …
crazytonyli May 14, 2026
9b38ea8
Extend MediaTrackerEvent with M2 cases and route them through MediaTr…
crazytonyli May 14, 2026
ae467e0
Rewrite Media Library V2 view model and view for M2 grid
crazytonyli May 14, 2026
affa0bc
Address code review findings for M2 grid
crazytonyli May 14, 2026
db0fc04
Address second-round code review findings for M2 grid
crazytonyli May 14, 2026
26c397a
Fix aspect-ratio grid image overlap
crazytonyli May 14, 2026
d9038f5
Update code comments
crazytonyli May 14, 2026
18ef295
Simplify grid view layout
crazytonyli May 14, 2026
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
7 changes: 7 additions & 0 deletions Modules/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,13 @@ let package = Package(
.product(name: "Logging", package: "swift-log")
]
),
.testTarget(
name: "WordPressMediaLibraryTests",
dependencies: [
.target(name: "WordPressMediaLibrary"),
.product(name: "WordPressAPI", package: "wordpress-rs")
]
),
.target(
name: "ShareExtensionCore",
dependencies: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ public protocol MediaTracker {

public enum MediaTrackerEvent: Sendable {
case mediaLibraryOpened
// M2-M7 add more cases here.
case mediaLibraryFilterChanged(kind: MediaKind?) // nil = "All"
case mediaLibrarySearched(queryLength: Int) // fires AFTER 300ms debounce trailing edge; non-empty only
case mediaLibraryGridModeToggled(isAspectRatio: Bool)
}

/// No-op tracker for previews and module-internal default-construction.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Foundation

/// Formats a video duration (seconds) as `m:ss` for durations under one hour
/// and `h:mm:ss` from one hour up. **Intentionally locale-neutral**: V1's
/// `DateComponentsFormatter`-based output varies in non-Latin-numeral
/// locales (Arabic, Hindi, etc.), but the duration badge uses a
/// `.monospaced` font and reads more like a timecode than a sentence — a
/// stable `digit:digit` output is the better fit. This is a small,
/// deliberate deviation from V1's `SiteMediaCollectionCellViewModel.swift`'s
/// `makeString(forDuration:)`.
enum MediaGridDuration {
static func string(forSeconds seconds: UInt32) -> String {
let total = Int(seconds)
let hours = total / 3600
let minutes = (total % 3600) / 60
let secs = total % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, secs)
}
return String(format: "%d:%02d", minutes, secs)
}
}
33 changes: 33 additions & 0 deletions Modules/Sources/WordPressMediaLibrary/Models/MediaGridFilter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import Foundation
import WordPressAPI
import WordPressAPIInternal

/// Filter state for the Media Library grid. Hashable so it can drive
/// `.task(id: viewModel.filter)` — when any field changes, SwiftUI
/// cancels the outstanding refresh/observer tasks and re-runs them
/// against the freshly-rebuilt collection.
struct MediaGridFilter: Hashable {
var kind: MediaKind? // nil = all kinds
var search: String // empty = no constraint

static let initial = MediaGridFilter(kind: nil, search: "")

func with(kind: MediaKind?) -> Self {
var copy = self
copy.kind = kind
return copy
}

func with(search: String) -> Self {
var copy = self
copy.search = search
return copy
}

func asMediaListFilter() -> MediaListFilter {
MediaListFilter(
search: search.isEmpty ? nil : search,
mediaType: kind?.asMediaTypeParam
)
}
}
145 changes: 145 additions & 0 deletions Modules/Sources/WordPressMediaLibrary/Models/MediaGridItem.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import Foundation
import CoreGraphics
import WordPressAPI
import WordPressAPIInternal

/// Display model for a single grid cell. Extracted from
/// `MediaMetadataCollectionItem` at construction time so the cell view is
/// purely render — no FFI calls during `body`.
struct MediaGridItem: Identifiable, Equatable {
let id: Int64
let kind: MediaKind
let displayTitle: String
let thumbnailURL: URL? // image or video kind; the cell picks the right CachedAsyncImage initializer based on kind
let aspectRatio: CGFloat? // image kind only; width / height
let durationString: String? // video kind only
let state: State
let accessibilityLabel: String

enum State: Equatable {
case loaded(isUpToDate: Bool)
case loading
case error(message: String)
}

init(item: MediaMetadataCollectionItem) {
switch item.state {
case .fresh(let entity):
self.init(media: entity.data, id: item.id, state: .loaded(isUpToDate: true))
case .stale(let entity):
self.init(media: entity.data, id: item.id, state: .loaded(isUpToDate: false))
case .fetchingWithData(let entity):
self.init(media: entity.data, id: item.id, state: .loading)
case .failedWithData(let message, let entity):
self.init(media: entity.data, id: item.id, state: .error(message: message))
case .fetching, .missing:
self.init(placeholderID: item.id, state: .loading)
case .failed(let message):
self.init(placeholderID: item.id, state: .error(message: message))
}
}

/// Designated initializer for data-bearing states. Initializes every
/// stored property exactly once.
private init(media: MediaWithEditContext, id: Int64, state: State) {
let payload = media.mediaDetails.parseAsMimeType(mimeType: media.mimeType)
let kind = payload.flatMap(MediaKind.init(payload:)) ?? .document

self.id = id
self.kind = kind
self.displayTitle = MediaGridItem.makeTitle(media: media)
self.state = state
self.accessibilityLabel = MediaGridItem.makeAccessibilityLabel(media: media, kind: kind)

switch payload {
case .image(let imageDetails):
self.thumbnailURL = MediaThumbnailURL.pick(from: imageDetails, sourceUrl: media.sourceUrl)
if imageDetails.width > 0, imageDetails.height > 0 {
self.aspectRatio = CGFloat(imageDetails.width) / CGFloat(imageDetails.height)
} else {
self.aspectRatio = nil
}
self.durationString = nil
case .video(let videoDetails):
// For video, `thumbnailURL` carries the video file URL itself —
// the cell renders it via `CachedAsyncImage(videoUrl:)`, which
// extracts a frame for the thumbnail (V1 parity).
self.thumbnailURL = URL(string: media.sourceUrl)
self.aspectRatio = nil
self.durationString = MediaGridDuration.string(forSeconds: videoDetails.length)
case .audio, .document, .none:
self.thumbnailURL = nil
self.aspectRatio = nil
self.durationString = nil
}
}

/// Designated initializer for payload-less states. Initializes every
/// stored property exactly once. The accessibility label branches on
/// `state` because the same initializer covers both `.fetching` /
/// `.missing` (genuinely loading) and `.failed` (error without payload):
/// VoiceOver shouldn't hear "Loading media" while the cell shows an
/// error icon.
private init(placeholderID id: Int64, state: State) {
self.id = id
self.kind = .image // best-effort placeholder; cell renders a uniformly-grey square under the loading / error overlay
self.displayTitle = ""
self.thumbnailURL = nil
self.aspectRatio = nil
self.durationString = nil
self.state = state
switch state {
case .error:
self.accessibilityLabel = Strings.accessibilityErrorMedia
case .loading, .loaded:
self.accessibilityLabel = Strings.accessibilityLoadingMedia
}
}

private static func makeTitle(media: MediaWithEditContext) -> String {
let raw = (media.title.raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if !raw.isEmpty { return raw }
let slug = media.slug.trimmingCharacters(in: .whitespacesAndNewlines)
if !slug.isEmpty { return slug }
if let filename = filename(from: media.sourceUrl), !filename.isEmpty {
return filename
}
return Strings.untitled
}

private static func makeAccessibilityLabel(media: MediaWithEditContext, kind: MediaKind) -> String {
// `WpGmtDateTime` is a typealias for `Date` in the wordpress-rs Swift
// binding, so `media.dateGmt` is already a proper Date — no string
// parsing needed. The DateFormatter applies the user's locale + time
// zone, so a UTC dateGmt renders as local time, matching the V1 cell
// view-model's behavior.
let date = MediaGridItem.accessibilityDateFormatter.string(from: media.dateGmt)
switch kind {
case .image:
return String.localizedStringWithFormat(Strings.accessibilityLabelImage, date)
case .video:
return String.localizedStringWithFormat(Strings.accessibilityLabelVideo, date)
case .audio:
return String.localizedStringWithFormat(Strings.accessibilityLabelAudio, date)
case .document:
// V1 falls back to filename for documents; if filename can't be
// derived, use the date so the row is still describable.
let filenameOrDate = filename(from: media.sourceUrl) ?? date
return String.localizedStringWithFormat(Strings.accessibilityLabelDocument, filenameOrDate)
}
}

private static func filename(from sourceUrl: String) -> String? {
guard let url = URL(string: sourceUrl) else { return nil }
let last = url.lastPathComponent
return last.isEmpty ? nil : last
}

private static let accessibilityDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.doesRelativeDateFormatting = true
formatter.dateStyle = .full
formatter.timeStyle = .short
return formatter
}()
}
70 changes: 70 additions & 0 deletions Modules/Sources/WordPressMediaLibrary/Models/MediaKind.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import Foundation
import WordPressAPI
import WordPressAPIInternal

/// The enum itself is public so `MediaTrackerEvent.mediaLibraryFilterChanged(kind:)`
/// can carry it across the module boundary; the app-target analytics
/// adapter reads `rawValue` for its property dict. The `MediaDetailsPayload`
/// initializer, the `MediaTypeParam` mapping, and the UI helpers
/// (`title` / `systemImageName`) all stay module-internal
/// — they're used only inside the module and in `@testable` tests, so
/// there's no reason to leak `WordPressAPIInternal` types through the
/// public surface.
public enum MediaKind: String, CaseIterable, Hashable, Sendable {
case image, video, audio, document

init?(payload: MediaDetailsPayload) {
switch payload {
case .image: self = .image
case .video: self = .video
case .audio: self = .audio
case .document: self = .document
}
}

/// Maps the V2 grid filter selection to the wordpress-rs REST query
/// parameter. **Known narrowing for `.document`:** V1's "Documents"
/// bucket includes attachments the system classifies as `text/*` (e.g.
/// `.txt`, `.md`) alongside the `application/*` MIME family, because V1
/// builds its filter locally against Core Data's `mediaTypeString`. V2
/// goes through the wordpress-rs `MediaListFilter.mediaType` parameter,
/// which is a single optional `MediaTypeParam` — we can't OR
/// `.application` and `.text` in one query without an upstream
/// wordpress-rs change. V2 ships with the narrower `.application`-only
/// document bucket; full V1 parity here depends on a wordpress-rs
/// change to support multi-value media-type filtering.
var asMediaTypeParam: MediaTypeParam {
switch self {
case .image: .image
case .video: .video
case .audio: .audio
case .document: .application
}
}
}

// MARK: - UI helpers
//
// These properties live in the same file as the enum but in their own
// extension so they're easy to spot and so the base enum (used by the
// public analytics surface) doesn't pull in localized strings unnecessarily.

extension MediaKind {
var title: String {
switch self {
case .image: Strings.filterImages
case .video: Strings.filterVideos
case .audio: Strings.filterAudio
case .document: Strings.filterDocuments
}
}

var systemImageName: String {
switch self {
case .image: "photo"
case .video: "video"
case .audio: "waveform"
case .document: "folder"
}
}
}
67 changes: 0 additions & 67 deletions Modules/Sources/WordPressMediaLibrary/Models/MediaListItem.swift

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Foundation
import WordPressAPI
import WordPressAPIInternal

/// Picks a thumbnail URL from `ImageMediaDetails.sizes`, falling back through
/// a preference list and finally to `sourceUrl`. The 4-per-row phone grid
/// renders ~270px cells at @3x — `medium` (default 300px) is the closest
/// well-known size; `thumbnail` (150px) covers the case where only the small
/// image has been generated server-side.
enum MediaThumbnailURL {
private static let preferenceOrder = ["medium", "medium_large", "large", "thumbnail"]

static func pick(from imageDetails: ImageMediaDetails, sourceUrl: String) -> URL? {
for key in preferenceOrder {
if let scaled = imageDetails.sizes?[key], let url = URL(string: scaled.sourceUrl) {
return url
}
}
return URL(string: sourceUrl)
}
}
Loading