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
@@ -0,0 +1,69 @@
import Testing
import UniformTypeIdentifiers
import WordPressData
import WordPressMediaLibrary
@testable import WordPress

@MainActor
struct ExternalRemoteMediaAdapterTests {

@Test func stockPhotos_prefersAssetNameOverURLStem() {
let asset = StubExternalMediaAsset(
id: "1",
name: "Sunset over the harbor",
caption: "by Foo",
largeURL: URL(string: "https://images.pexels.com/photos/1234/pexels-photo.jpg")!,
thumbnailURL: URL(string: "https://example.com/thumb.jpg")!
)
let media = ExternalRemoteMedia(stockPhotosAsset: asset)
#expect(media.suggestedName == "Sunset over the harbor")
#expect(media.contentType == .jpeg)
#expect(media.caption == "by Foo")
}

@Test func tenor_prefersURLStemOverAssetName() {
let asset = StubExternalMediaAsset(
id: "g1",
name: "Excited Cat",
caption: "",
largeURL: URL(string: "https://media.tenor.com/abc/excited-cat.gif")!,
thumbnailURL: URL(string: "https://example.com/t.gif")!
)
let media = ExternalRemoteMedia(tenorAsset: asset)
#expect(media.suggestedName == "excited-cat")
#expect(media.contentType == .gif)
#expect(media.caption == nil)
}

@Test func tenor_fallsBackToDefault_whenURLStemIsEmpty() {
let asset = StubExternalMediaAsset(
id: "g2",
name: "",
caption: "",
largeURL: URL(string: "https://media.tenor.com/")!,
thumbnailURL: URL(string: "https://example.com/")!
)
let media = ExternalRemoteMedia(tenorAsset: asset)
#expect(media.suggestedName == "External Media")
}
}

/// Simple test stub for `ExternalMediaAsset` (a V1 app-target protocol
/// inheriting from `AnyObject` + `ExportableAsset` which is
/// `NSObjectProtocol`). Lives in this test file only.
private final class StubExternalMediaAsset: NSObject, ExternalMediaAsset {
let id: String
let name: String
let caption: String
let largeURL: URL
let thumbnailURL: URL
var assetMediaType: MediaType { .image }
init(id: String, name: String, caption: String, largeURL: URL, thumbnailURL: URL) {
self.id = id
self.name = name
self.caption = caption
self.largeURL = largeURL
self.thumbnailURL = thumbnailURL
super.init()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import Testing
import WordPressData
import WordPressMediaLibrary
@testable import WordPress

@MainActor
struct MediaLibraryRoutingExternalSourcesTests {

@Test func dotComBlog_jetpackEnabled_offersStockTenor() {
let context = ContextManager.forTesting().mainContext
let blog = ModelTestHelper.insertDotComBlog(context: context)
// `blog.wordPressComRestApi` is non-nil only when the account has a
// non-empty authToken (WPAccount+RestApi.swift:13-24). The default
// fixture leaves authToken empty, so set one before asserting.
blog.account?.authToken = "test-token"
try? context.save()
#expect(blog.wordPressComRestApi != nil)

// Stabilize the Jetpack-features gate. MediaPickerSource.freePhotos
// resolves to `blog.supports(.stockPhotos) && jetpackFeaturesEnabled()`.
// If `JetpackFeaturesRemovalCoordinator.currentAppUIType` is nil, the
// removal-phase fallback can disable Jetpack features and silently
// hide Stock Photos and Tenor in this test. Save/restore the override
// around the test so other suites aren't affected.
let savedAppUIType = JetpackFeaturesRemovalCoordinator.currentAppUIType
JetpackFeaturesRemovalCoordinator.currentAppUIType = .normal
defer { JetpackFeaturesRemovalCoordinator.currentAppUIType = savedAppUIType }

let options = MediaLibraryRouting.externalPickerOptions(for: blog)
let ids = options.map(\.id)
#expect(ids.contains("stockPhotos"))
#expect(ids.contains("tenor"))
}

@Test func selfHostedBlog_hidesStockAndTenor() {
let blog = ModelTestHelper.insertSelfHostedBlog(context: ContextManager.forTesting().mainContext)
let options = MediaLibraryRouting.externalPickerOptions(for: blog)
let ids = options.map(\.id)
#expect(!ids.contains("stockPhotos"))
#expect(!ids.contains("tenor")) // V1 parity: .freePhotos gate hides both
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,9 @@ final class ExternalMediaPickerViewController: UIViewController, UICollectionVie

switch source {
case .stockPhotos:
WPAnalytics.track(.tenorAccessed)
case .tenor:
WPAnalytics.track(.stockMediaAccessed)
case .tenor:
WPAnalytics.track(.tenorAccessed)
default:
assertionFailure("Unsupported source: \(source)")
}
Expand Down
74 changes: 73 additions & 1 deletion WordPress/Classes/ViewRelated/Media/MediaLibraryRouting.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import SwiftUI
import UIKit
import WordPressCore
import WordPressData
Expand Down Expand Up @@ -40,7 +41,78 @@ enum MediaLibraryRouting {
return MediaLibraryHostingController.make(
client: client,
tracker: tracker,
uploader: uploader
uploader: uploader,
externalPickerOptions: externalPickerOptions(for: blog)
)
}

/// Internal helper so tests can assert the option array without UI introspection.
/// Mirrors V1's effective gates from MediaPickerMenu+External.swift — note Tenor
/// uses `.freePhotos` (not `.freeGIFs`) to match V1's actual helper behavior.
static func externalPickerOptions(for blog: Blog) -> [ExternalMediaPickerOption] {
var options: [ExternalMediaPickerOption] = []

if MediaPickerSource.freePhotos(blog: blog).isEnabled,
let api = blog.wordPressComRestApi
{
options.append(
.init(
id: "stockPhotos",
label: Strings.stockPhotos,
systemImage: "photo.on.rectangle",
sheetContent: { delegate in
AnyView(StockPhotosPickerSheet(api: api, delegate: delegate))
}
)
)
}
// Tenor gate uses `.freePhotos` for strict V1 parity:
// V1's MediaPickerMenu+External.swift:42-45 guards makeFreeGIFAction
// on .freePhotos, not .freeGIFs. Mirroring that quirk.
if MediaPickerSource.freePhotos(blog: blog).isEnabled {
options.append(
.init(
id: "tenor",
label: Strings.tenorGIFs,
systemImage: "play.square.stack",
sheetContent: { delegate in
AnyView(TenorPickerSheet(delegate: delegate))
}
)
)
}
if MediaPickerSource.playground.isEnabled {
if #available(iOS 18.1, *) {
options.append(
.init(
id: "imagePlayground",
label: Strings.imagePlayground,
systemImage: "apple.image.playground",
sheetContent: { delegate in
AnyView(ImagePlaygroundPickerSheet(delegate: delegate))
}
)
)
}
}
return options
}
}

private enum Strings {
static let stockPhotos = NSLocalizedString(
"mediaLibrary.v2.addMenu.stockPhotos",
value: "Free Photo Library",
comment: "Add-menu item that opens the Stock Photos picker"
)
static let tenorGIFs = NSLocalizedString(
"mediaLibrary.v2.addMenu.tenorGIFs",
value: "Free GIF Library",
comment: "Add-menu item that opens the Tenor GIF picker"
)
static let imagePlayground = NSLocalizedString(
"mediaLibrary.v2.addMenu.imagePlayground",
value: "Image Playground",
comment: "Add-menu item that opens Apple's Image Playground"
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import SwiftUI
import UIKit
import WordPressMediaLibrary

/// SwiftUI wrapper around the V1 `ExternalMediaPickerViewController`, shared by
/// every external source that vends `ExternalMediaAsset`s (Stock Photos, Tenor).
/// Per-source differences (data source, welcome view, title, asset mapping) are
/// injected; the picker-to-delegate bridging is common to all of them.
struct ExternalMediaPickerSheet: View {
let title: String
let source: ExternalRemoteMediaSource
let makeDataSource: () -> ExternalMediaDataSource
let makeWelcomeView: () -> UIView
let mapAsset: (ExternalMediaAsset) -> ExternalRemoteMedia
let delegate: any ExternalMediaPickerDelegate
@Environment(\.dismiss) private var dismiss

var body: some View {
ExternalMediaPickerVCRepresentable(
title: title,
mediaSource: source.legacyMediaSource,
makeDataSource: makeDataSource,
makeWelcomeView: makeWelcomeView
) { selection in
// V1 picker uses a single didFinishWithSelection callback;
// empty array = cancel, non-empty = done.
if selection.isEmpty {
delegate.didCancel()
} else {
delegate.didPick(remoteMedia: selection.map(mapAsset), source: source)
}
dismiss()
}
.ignoresSafeArea()
}
}

private struct ExternalMediaPickerVCRepresentable: UIViewControllerRepresentable {
let title: String
let mediaSource: MediaSource
let makeDataSource: () -> ExternalMediaDataSource
let makeWelcomeView: () -> UIView
let onFinished: ([ExternalMediaAsset]) -> Void

func makeUIViewController(context: Context) -> UINavigationController {
let picker = ExternalMediaPickerViewController(
dataSource: makeDataSource(),
source: mediaSource,
allowsMultipleSelection: true
)
picker.title = title
picker.welcomeView = makeWelcomeView()
picker.delegate = context.coordinator
return UINavigationController(rootViewController: picker)
}

func updateUIViewController(_: UINavigationController, context: Context) {}

func makeCoordinator() -> Coordinator { Coordinator(onFinished: onFinished) }

final class Coordinator: NSObject, ExternalMediaPickerViewDelegate {
let onFinished: ([ExternalMediaAsset]) -> Void
init(onFinished: @escaping ([ExternalMediaAsset]) -> Void) {
self.onFinished = onFinished
}
func externalMediaPickerViewController(
_ viewController: ExternalMediaPickerViewController,
didFinishWithSelection selection: [ExternalMediaAsset]
) {
// V1 callback runs on the main thread (it's a UIKit dismiss path).
// assumeIsolated bridges into the @MainActor closure without an
// async hop. Same pattern as MediaPickerController.swift:78-82.
MainActor.assumeIsolated {
onFinished(selection)
}
}
}
}

private extension ExternalRemoteMediaSource {
/// Maps the module's source enum onto the V1 `MediaSource` the legacy
/// picker expects for its own analytics / selection wiring.
var legacyMediaSource: MediaSource {
switch self {
case .stockPhotos: .stockPhotos
case .tenor: .tenor
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import Foundation
import UniformTypeIdentifiers
import WordPressMediaLibrary

extension ExternalRemoteMedia {
/// Stock Photos: prefer the asset's title (Pexels names are descriptive
/// and V1 preserves them via `MediaImageExporter(filename:)`). Falls back
/// to URL basename, then to the localized "External Media" default.
init(stockPhotosAsset asset: ExternalMediaAsset) {
self.init(
url: asset.largeURL,
suggestedName: Self.normalizeStem(preferred: asset.name, fallback: asset.largeURL),
contentType: .jpeg,
caption: asset.caption.isEmpty ? nil : asset.caption
)
}

/// Tenor: prefer the URL basename (V1's GIF export at
/// `MediaExternalExporter.swift:63-74` ignores the title and uses
/// `url.lastPathComponent`). Falls back to URL-basename re-derivation,
/// then to the localized default.
init(tenorAsset asset: ExternalMediaAsset) {
let urlStem = asset.largeURL.deletingPathExtension().lastPathComponent
self.init(
url: asset.largeURL,
suggestedName: Self.normalizeStem(preferred: urlStem, fallback: asset.largeURL),
contentType: .gif,
caption: asset.caption.isEmpty ? nil : asset.caption
)
}

private static func normalizeStem(preferred: String, fallback: URL) -> String {
let stem = sanitize(preferred)
if !stem.isEmpty { return stem }
let urlStem = sanitize(fallback.deletingPathExtension().lastPathComponent)
if !urlStem.isEmpty { return urlStem }
return Strings.defaultExternalMediaStem
}

private static func sanitize(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
let stripped = (trimmed as NSString).deletingPathExtension
// Treat input composed entirely of path separators as empty so the
// caller falls through to the URL-basename / localized-default chain.
let withoutSeparators = stripped.replacingOccurrences(of: "/", with: "")
if withoutSeparators.isEmpty { return "" }
return
stripped
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "\0", with: "")
}
}

private enum Strings {
static let defaultExternalMediaStem = NSLocalizedString(
"mediaLibrary.v2.externalMedia.defaultStem",
value: "External Media",
comment: "Fallback filename stem when an external picker provides no usable name"
)
}
Loading