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 @@ -38,7 +38,7 @@ struct MediaGridItem: Identifiable, Equatable {
}

/// Designated initializer for data-bearing states. Initializes every
/// stored property exactly once.
/// stored property exactly once. Reached only via `init(item:)`.
private init(media: MediaWithEditContext, id: Int64, state: State) {
let payload = media.mediaDetails.parseAsMimeType(mimeType: media.mimeType)
let kind = payload.flatMap(MediaKind.init(payload:)) ?? .document
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,27 @@ public struct DownloadableMediaItem: Sendable {
}
}

/// Output of a successful `downloadForSharing(items:)`. `urls` are the local
/// file URLs to hand to `UIActivityViewController`. `cleanup`, when non-nil,
/// removes the service-owned temp scope (typically the per-batch directory
/// under `temporaryDirectory`); `MediaDetailViewModel.SharePayload` invokes
/// it on activity-sheet dismissal or selection-mode exit. The closure is the
/// only ownership signal — callers do not infer ownership from URL paths.
public struct BulkShareDownloadResult: Sendable {
public let urls: [URL]
public let cleanup: (@Sendable () -> Void)?

public init(urls: [URL], cleanup: (@Sendable () -> Void)? = nil) {
self.urls = urls
self.cleanup = cleanup
}
}

/// App-injected authenticated downloader. Returns local file URLs suitable
/// for `UIActivityViewController` activity items. Throws on any download or
/// for `UIActivityViewController` activity items plus an optional cleanup
/// closure for the service-owned temp scope. 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]
func downloadForSharing(items: [DownloadableMediaItem]) async throws -> BulkShareDownloadResult
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import Foundation
import WordPressAPI

/// Filename derivation for the Share action. The primitive `suggested(...)`
/// form is the canonical implementation; both the single-item detail VM
/// (which has a `MediaDetailDisplayModel` snapshot) and the bulk-share
/// path (which has a `MediaWithEditContext`) call through to it via
/// matching primitive fields. The `suggested(for media:)` overload is a
/// convenience for the bulk path.
enum MediaShareFilename {
/// Picks a human-meaningful filename in this priority order: trimmed
/// title, trimmed slug, URL last-path-component, then "media-<id>".
/// Returns `nil` only if every fallback also fails; production callers
/// always have an `id`, so the "media-<id>" tail is the effective floor.
///
/// Each candidate is checked with `isUsable(_:)`, which rejects the
/// filesystem-special components `.` and `..` (and the empty string).
/// Title and slug are user-controlled site data; without the rejection,
/// a literal "." title with no MIME-derived extension would resolve to
/// the batch directory itself when appended as a path component, and
/// the share `moveItem` would fail silently.
static func suggested(title: String?, slug: String, sourceUrl: String, id: Int64) -> String? {
let trimmedTitle = (title ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if isUsable(trimmedTitle) { return trimmedTitle }

let trimmedSlug = slug.trimmingCharacters(in: .whitespacesAndNewlines)
if isUsable(trimmedSlug) { return trimmedSlug }

if let last = URL(string: sourceUrl)?.lastPathComponent, isUsable(last) {
return last
}

return "media-\(id)"
}

private static func isUsable(_ candidate: String) -> Bool {
!candidate.isEmpty && candidate != "." && candidate != ".."
}

/// Convenience for callers that already hold a full `MediaWithEditContext`
/// (the bulk-share path in `MediaLibraryViewModel.startBulkShare()`).
static func suggested(for media: MediaWithEditContext) -> String? {
suggested(title: media.title.raw, slug: media.slug, sourceUrl: media.sourceUrl, id: media.id)
}
}
89 changes: 89 additions & 0 deletions Modules/Sources/WordPressMediaLibrary/Strings/Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -498,4 +498,93 @@ enum Strings {
value: "Delete",
comment: "Destructive button title in the delete-confirmation alert"
)

// MARK: - Selection mode

static let selectionSelect = NSLocalizedString(
"mediaLibrary.selection.select",
value: "Select",
comment: "Nav-bar button that enters selection mode in the V2 Media Library."
)

static let selectionTitleEmpty = NSLocalizedString(
"mediaLibrary.selection.title.empty",
value: "Select Items",
comment: "Bottom toolbar title shown in selection mode when no items are selected."
)

static let selectionTitleImageSingular = NSLocalizedString(
"mediaLibrary.selection.title.image.singular",
value: "%1$d Image Selected",
comment: "Bottom toolbar title when exactly one image is selected. %1$d is the count (always 1 here)."
)

static let selectionTitleImagePlural = NSLocalizedString(
"mediaLibrary.selection.title.image.plural",
value: "%1$d Images Selected",
comment: "Bottom toolbar title when 2+ images are selected. %1$d is the count."
)

static let selectionTitleItemSingular = NSLocalizedString(
"mediaLibrary.selection.title.item.singular",
value: "%1$d Item Selected",
comment: "Bottom toolbar title when exactly one item is selected (mixed or non-image)."
)

static let selectionTitleItemPlural = NSLocalizedString(
"mediaLibrary.selection.title.item.plural",
value: "%1$d Items Selected",
comment: "Bottom toolbar title when 2+ items are selected (mixed or non-image)."
)

// The single-item delete-confirmation title and the destructive "Delete"
// action label are shared with the detail screen (`detailDeleteConfirmation`
// / `detailDeleteAction`); only the multi-item confirmation is selection-only.
static let selectionDeleteConfirmationMany = NSLocalizedString(
"mediaLibrary.selection.deleteConfirmation.many",
value: "Are you sure you want to permanently delete these items?",
comment: "Confirmation dialog title when deleting multiple items from the V2 Media Library."
)

static let selectionDeleteFailedMessage = NSLocalizedString(
"mediaLibrary.selection.deleteFailed.message",
value: "Some items couldn't be deleted. Please try again.",
comment: "Alert message shown when a bulk delete fails for some or all of the selected items."
)

static let selectionShareNothingMessage = NSLocalizedString(
"mediaLibrary.selection.shareNothing.message",
value: "The selected items can't be shared.",
comment: "Alert message shown when none of the selected items has a shareable media URL."
)

static let selectionDeleteAccessibilityLabel = NSLocalizedString(
"mediaLibrary.selection.delete.accessibilityLabel",
value: "Delete selected items",
comment: "VoiceOver label for the icon-only Trash button in the selection-mode bottom toolbar."
)

static let accessibilitySelected = NSLocalizedString(
"mediaLibrary.selection.accessibility.selected",
value: "Selected",
comment: "VoiceOver value for a cell whose checkmark badge is on in selection mode."
)

static let accessibilityNotSelected = NSLocalizedString(
"mediaLibrary.selection.accessibility.notSelected",
value: "Not selected",
comment: "VoiceOver value for a cell whose checkmark badge is off in selection mode."
)

static let shareAccessibilityPreparing = NSLocalizedString(
"mediaLibrary.selection.share.accessibility.preparing",
value: "Preparing items to share",
comment: "VoiceOver label for the inline progress indicator while a bulk share is downloading."
)

static let cellDeletingAccessibilityValue = NSLocalizedString(
"mediaLibrary.selection.cell.accessibility.deleting",
value: "Deleting",
comment: "VoiceOver value appended to a cell that has an in-flight delete request."
)
}
45 changes: 29 additions & 16 deletions Modules/Sources/WordPressMediaLibrary/Views/BannerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,42 @@ import SwiftUI

struct BannerView: View {
let summary: MediaLibraryViewModel.BannerSummary
let onTap: () -> Void
let onTap: (() -> Void)?

var body: some View {
Button(action: onTap) {
HStack(spacing: 12) {
if summary.pendingCount > 0 {
ProgressView()
.progressViewStyle(.circular)
.controlSize(.small)
}
Text(label)
.font(.subheadline)
Spacer()
if let onTap {
Button(action: onTap) {
content
}
.buttonStyle(.plain)
.padding(.horizontal, 16)
.padding(.vertical, 8)
} else {
content
.padding(.horizontal, 16)
.padding(.vertical, 8)
.accessibilityElement(children: .combine)
}
}

private var content: some View {
HStack(spacing: 12) {
if summary.pendingCount > 0 {
ProgressView()
.progressViewStyle(.circular)
.controlSize(.small)
}
Text(label)
.font(.subheadline)
Spacer()
if onTap != nil {
Image(systemName: "chevron.right")
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.thinMaterial, in: .rect(cornerRadius: 12))
}
.buttonStyle(.plain)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.padding(.vertical, 10)
.background(.thinMaterial, in: .rect(cornerRadius: 12))
}

private var label: String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,17 +57,20 @@ struct MediaDetailView: View {
}
.sheet(item: $viewModel.sharePayload) { payload in
ShareSheetRepresentable(urls: payload.urls) { completed in
viewModel.reportShareDismissed(completed: completed)
viewModel.reportShareDismissed(payload, completed: completed)
}
.onAppear { viewModel.shareSheetDidPresent() }
}
.onChange(of: viewModel.shouldPop) { _, shouldPop in
if shouldPop { dismiss() }
}
.task { viewModel.onAppear() }
// The in-flight guards keep anything from covering this screen while
// a share prepares, so disappearance means a real pop (or a tab
// switch, which is an acceptable reason to cancel too).
.onDisappear { viewModel.cancelShare() }
// switch, which is an acceptable reason to cancel too). Besides
// cancelling, the VM releases a share payload whose sheet never
// presented, closing the pop-during-download temp-file leak.
.onDisappear { viewModel.viewDidDisappear() }
}

@ViewBuilder private var editableFieldsSection: some View {
Expand Down
Loading