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
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ public enum MediaTrackerEvent: Sendable {
// Upload events:
case mediaLibraryAdded(source: MediaUploadSource, kind: MediaKind)
case mediaLibraryUploadRetried

// Detail / Edit events:
case mediaLibraryPreviewedItem
case mediaLibraryEditedItemMetadata
case mediaLibraryDeletedItems(count: Int)
case siteMediaShareTapped(count: Int)
case mediaLibrarySharedItemLink
}

public enum MediaUploadSource: Sendable {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Foundation
import WordPressAPI
import WordPressAPIInternal

/// Snapshot model the V2 detail screen reads and binds to. Built once from
/// a `MediaWithEditContext` at detail-VM init, then mutated in place when a
/// per-field save returns its server response. Keeping the merge logic
/// against a Swift-native value type avoids rebuilding a 27-arg UniFFI
/// struct on every save.
struct MediaDetailDisplayModel: Equatable, Sendable {
let id: Int64
var title: String?
var caption: String
var description: String
var altText: String
let mimeType: String
let sourceUrl: String
let mediaDetails: MediaDetails
let dateGmt: Date
let slug: String
let kind: MediaKind

init(media: MediaWithEditContext) {
self.id = media.id
self.title = media.title.raw
self.caption = media.caption.raw
self.description = media.description.raw
self.altText = media.altText
self.mimeType = media.mimeType
self.sourceUrl = media.sourceUrl
self.mediaDetails = media.mediaDetails
self.dateGmt = media.dateGmt
self.slug = media.slug
self.kind = .from(mimeType: media.mimeType)
}

/// Adopts the server's value for one field after a successful save. The
/// other fields stay at their local values so a concurrent different-
/// field save can't clobber siblings by returning a stale snapshot.
mutating func apply(_ field: MediaEditableField, fromServer server: MediaWithEditContext) {
switch field {
case .title: self.title = server.title.raw
case .caption: self.caption = server.caption.raw
case .description: self.description = server.description.raw
case .altText: self.altText = server.altText
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import Foundation

/// Editable metadata fields on the V2 detail screen. Each case maps to one
/// `MediaUpdateParams` slot. Alt-text visibility is gated at the
/// `MediaDetailViewModel.visibleEditableFields` layer, not on this enum.
enum MediaEditableField: Hashable {
case title
case caption
case description
case altText

var localizedTitle: String {
switch self {
case .title: return Strings.detailFieldTitle
case .caption: return Strings.detailFieldCaption
case .description: return Strings.detailFieldDescription
case .altText: return Strings.detailFieldAltText
}
}

var placeholder: String {
switch self {
case .title: return Strings.detailFieldTitlePlaceholder
case .caption: return Strings.detailFieldCaptionPlaceholder
case .description: return Strings.detailFieldDescriptionPlaceholder
case .altText: return Strings.detailFieldAltTextPlaceholder
}
}

var hint: String {
switch self {
case .title: return Strings.detailFieldTitleHint
case .caption: return Strings.detailFieldCaptionHint
case .description: return Strings.detailFieldDescriptionHint
case .altText: return Strings.detailFieldAltTextHint
}
}

func value(in display: MediaDetailDisplayModel) -> String {
switch self {
case .title: return display.title ?? ""
case .caption: return display.caption
case .description: return display.description
case .altText: return display.altText
}
}
}
12 changes: 12 additions & 0 deletions Modules/Sources/WordPressMediaLibrary/Models/MediaKind.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ public enum MediaKind: String, CaseIterable, Hashable, Sendable {
self = .document
}
}

/// Derives the kind directly from a MIME-type string. Mirrors the
/// prefix logic in wordpress-rs' `MediaDetails::parse_as_mime_type`
/// (`image/*`, `video/*`, `audio/*`, else document) but avoids the
/// FFI call and full-payload JSON deserialization those callers pay
/// for when only the kind tag is needed.
static func from(mimeType: String) -> MediaKind {
if mimeType.hasPrefix("image/") { return .image }
if mimeType.hasPrefix("video/") { return .video }
if mimeType.hasPrefix("audio/") { return .audio }
return .document
}
}

// MARK: - UI helpers
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Foundation
import WordPressAPI
import WordPressAPIInternal

extension MediaMetadataCollectionItem {
/// Extracts the `MediaWithEditContext` from data-bearing states.
/// Returns nil for placeholder states (.fetching / .missing / .failed)
/// where no media payload is carried.
var resolvedMedia: MediaWithEditContext? {
switch state {
case .fresh(let entity): return entity.data
case .stale(let entity): return entity.data
case .fetchingWithData(let entity): return entity.data
case .failedWithData(_, let entity): return entity.data
case .fetching, .missing, .failed: return nil
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Foundation
import UIKit

/// App-injected UIKit navigation seam for the V2 Media Library detail flow.
/// The module wraps SwiftUI screens (`MediaDetailView`, `MediaFieldEditorView`)
/// in `UIHostingController` and asks the navigator to push them onto the
/// hosting controller's outer `UINavigationController`. The app-target
/// adapter resolves the current nav controller at push time.
///
/// Why: hosting `MediaLibraryView` inside an outer `UINavigationController`
/// AND wrapping its body in a SwiftUI `NavigationStack` produces a stacked
/// double nav bar. Bridging pushes through UIKit avoids the nested-stack
/// problem entirely.
@MainActor
public protocol MediaDetailNavigator: AnyObject {
func push(_ viewController: UIViewController)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Foundation

/// Information the share path needs to authenticate, download, and name a
/// single item. The URL and MIME type drive the request and filename.
public struct DownloadableMediaItem: Sendable {
public let sourceUrl: URL
public let mimeType: String?
public let suggestedFilename: String?

public init(sourceUrl: URL, mimeType: String?, suggestedFilename: String?) {
self.sourceUrl = sourceUrl
self.mimeType = mimeType
self.suggestedFilename = suggestedFilename
}
}

/// App-injected authenticated downloader. Returns local file URLs suitable
/// for `UIActivityViewController` activity items. Throws on any download or
/// auth failure; the detail VM surfaces the error in `shareErrorMessage`.
@MainActor
public protocol MediaDetailShareService: AnyObject {
func downloadForSharing(items: [DownloadableMediaItem]) async throws -> [URL]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Foundation

/// App-injected opener for the URL row on the V2 detail screen. App-target
/// implementation wraps `WebViewControllerFactory.controller(url:blog:source:)`
/// and pushes onto the resolved nav controller.
@MainActor
public protocol MediaDetailURLOpener: AnyObject {
func open(_ url: URL, mediaTitle: String?)
}
184 changes: 184 additions & 0 deletions Modules/Sources/WordPressMediaLibrary/Strings/Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -314,4 +314,188 @@ enum Strings {
value: "Choose File",
comment: "Add-menu item that opens the system file picker."
)

// MARK: - Detail — fields

static let detailFieldTitle = NSLocalizedString(
"mediaLibrary.detail.field.title.label",
value: "Title",
comment: "Label for the title field on the media detail screen"
)
static let detailFieldCaption = NSLocalizedString(
"mediaLibrary.detail.field.caption.label",
value: "Caption",
comment: "Label for the caption field on the media detail screen"
)
static let detailFieldDescription = NSLocalizedString(
"mediaLibrary.detail.field.description.label",
value: "Description",
comment: "Label for the description field on the media detail screen"
)
static let detailFieldAltText = NSLocalizedString(
"mediaLibrary.detail.field.altText.label",
value: "Alt Text",
comment: "Label for the alt text field on the media detail screen"
)

static let detailFieldTitlePlaceholder = NSLocalizedString(
"mediaLibrary.detail.field.title.placeholder",
value: "Title",
comment: "Placeholder for the title editor"
)
static let detailFieldCaptionPlaceholder = NSLocalizedString(
"mediaLibrary.detail.field.caption.placeholder",
value: "Caption",
comment: "Placeholder for the caption editor"
)
static let detailFieldDescriptionPlaceholder = NSLocalizedString(
"mediaLibrary.detail.field.description.placeholder",
value: "Description",
comment: "Placeholder for the description editor"
)
static let detailFieldAltTextPlaceholder = NSLocalizedString(
"mediaLibrary.detail.field.altText.placeholder",
value: "Alt text",
comment: "Placeholder for the alt text editor"
)

static let detailFieldTitleHint = NSLocalizedString(
"mediaLibrary.detail.field.title.hint",
value: "Image title",
comment: "Hint shown under the title editor"
)
static let detailFieldCaptionHint = NSLocalizedString(
"mediaLibrary.detail.field.caption.hint",
value: "Image caption",
comment: "Hint shown under the caption editor"
)
static let detailFieldDescriptionHint = NSLocalizedString(
"mediaLibrary.detail.field.description.hint",
value: "Image description",
comment: "Hint shown under the description editor"
)
static let detailFieldAltTextHint = NSLocalizedString(
"mediaLibrary.detail.field.altText.hint",
value: "Alt text",
comment: "Hint shown under the alt text editor"
)

static let detailShareErrorInvalidURL = NSLocalizedString(
"mediaLibrary.detail.share.error.invalidURL",
value: "Source URL is invalid.",
comment: "Error shown when share fails due to an invalid source URL."
)
static let detailShareCancelAccessibility = NSLocalizedString(
"mediaLibrary.detail.share.cancelAccessibility",
value: "Cancel share",
comment: "Accessibility label for the in-progress share spinner; tapping it cancels the share download."
)

static let detailPreviewImageAccessibility = NSLocalizedString(
"mediaLibrary.detail.preview.imageAccessibility",
value: "Image preview",
comment: "Accessibility label for the image preview header."
)
static let detailPreviewVideoAccessibility = NSLocalizedString(
"mediaLibrary.detail.preview.videoAccessibility",
value: "Video preview",
comment: "Accessibility label for the video preview header."
)
static let detailPreviewAudioAccessibility = NSLocalizedString(
"mediaLibrary.detail.preview.audioAccessibility",
value: "Audio",
comment: "Accessibility label for the audio icon header."
)
static let detailPreviewDocumentAccessibility = NSLocalizedString(
"mediaLibrary.detail.preview.documentAccessibility",
value: "Document",
comment: "Accessibility label for the document icon header."
)

static let commonDone = NSLocalizedString(
"mediaLibrary.common.done",
value: "Done",
comment: "Confirmation action — used in editor toolbars"
)
static let commonOK = NSLocalizedString(
"mediaLibrary.common.ok",
value: "OK",
comment: "Acknowledgement action — used in alert dismissals"
)
static let commonCancel = NSLocalizedString(
"mediaLibrary.common.cancel",
value: "Cancel",
comment: "Cancel action — used in alert dismissals"
)
static let commonShare = NSLocalizedString(
"mediaLibrary.common.share",
value: "Share",
comment: "Accessibility label for the share button"
)

static let detailMetadataURL = NSLocalizedString(
"mediaLibrary.detail.metadata.url",
value: "URL",
comment: "Label for the URL row on the media detail screen"
)
static let detailMetadataFileName = NSLocalizedString(
"mediaLibrary.detail.metadata.fileName",
value: "File Name",
comment: "Label for the file name row on the media detail screen"
)
static let detailMetadataFileType = NSLocalizedString(
"mediaLibrary.detail.metadata.fileType",
value: "File Type",
comment: "Label for the file type row on the media detail screen"
)
static let detailMetadataFileSize = NSLocalizedString(
"mediaLibrary.detail.metadata.fileSize",
value: "File Size",
comment: "Label for the file size row on the media detail screen"
)
static let detailMetadataDimensions = NSLocalizedString(
"mediaLibrary.detail.metadata.dimensions",
value: "Dimensions",
comment: "Label for the dimensions row on the media detail screen"
)
static let detailMetadataUploaded = NSLocalizedString(
"mediaLibrary.detail.metadata.uploaded",
value: "Uploaded",
comment: "Label for the uploaded-date row on the media detail screen"
)
static let detailMetadataMimeType = NSLocalizedString(
"mediaLibrary.detail.metadata.mimeType",
value: "MIME Type",
comment: "Label for the MIME type row on the media detail screen"
)
static let detailIdFooter = NSLocalizedString(
"mediaLibrary.detail.idFooter",
value: "ID %1$lld",
comment: "Footer caption showing the entity ID; %1$lld is the media ID"
)
static let detailUnableToSaveTitle = NSLocalizedString(
"mediaLibrary.detail.unableToSaveTitle",
value: "Unable to save changes",
comment: "Title for the save-failure alert on the detail screen"
)
static let detailUnableToDeleteTitle = NSLocalizedString(
"mediaLibrary.detail.unableToDeleteTitle",
value: "Unable to delete media",
comment: "Title for the delete-failure alert on the detail screen"
)
static let detailUnableToShareTitle = NSLocalizedString(
"mediaLibrary.detail.unableToShareTitle",
value: "Unable to share media",
comment: "Title for the share-failure alert on the detail screen"
)
static let detailDeleteConfirmation = NSLocalizedString(
"mediaLibrary.detail.deleteConfirmation",
value: "Are you sure you want to permanently delete this item?",
comment: "Confirmation message in the delete alert"
)
static let detailDeleteAction = NSLocalizedString(
"mediaLibrary.detail.deleteAction",
value: "Delete",
comment: "Destructive button title in the delete-confirmation alert"
)
}
Loading