Skip to content

Commit

Permalink
Add support for video attachments in "Send Feedback" form (#23421)
Browse files Browse the repository at this point in the history
* Switch to ItemProviderMediaExporter

* Add support for uploading video

* Add support for previewing attachments

* Remove Preview attachment feature
  • Loading branch information
kean authored Jul 17, 2024
1 parent 0d09bab commit da188a6
Show file tree
Hide file tree
Showing 3 changed files with 95 additions and 65 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import Photos
import PhotosUI
import SupportProvidersSDK
import UniformTypeIdentifiers
import QuickLook
import QuickLookThumbnailing
import AVFoundation

@available(iOS 16, *)
struct ZendeskAttachmentsSection: View {
Expand All @@ -28,20 +31,34 @@ struct ZendeskAttachmentsSection: View {
private var attachmentsStack: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(viewModel.attachments) { attachment in
ZendeskAttachmentView(viewModel: attachment, onRemoveTapped: {
if let item = attachment.id as? PhotosPickerItem,
let index = selection.firstIndex(of: item) {
selection.remove(at: index)
}
})
}
ForEach(viewModel.attachments, content: makeView)
}
}._scrollClipDisabled()
}

private func makeView(for attachment: ZendeskAttachmentViewModel) -> some View {
ZendeskAttachmentView(viewModel: attachment) {
Section {
Button(role: .destructive, action: { removeAttachment(attachment) }) {
Label(Strings.removeAttachment, systemImage: "trash")
}
} header: {
if case .failed(let error) = attachment.status {
Text(error.localizedDescription)
}
}
}
}

private func removeAttachment(_ attachment: ZendeskAttachmentViewModel) {
if let item = attachment.id as? PhotosPickerItem,
let index = selection.firstIndex(of: item) {
selection.remove(at: index)
}
}

private var photosPicker: some View {
PhotosPicker(selection: $selection, maxSelectionCount: 5, matching: .images, preferredItemEncoding: .compatible) {
PhotosPicker(selection: $selection, maxSelectionCount: 5, preferredItemEncoding: .compatible) {
HStack {
Image(systemName: "paperclip")
Text(Strings.addAttachment)
Expand Down Expand Up @@ -71,23 +88,13 @@ final class ZendeskAttachmentsSectionViewModel: ObservableObject {
}

@available(iOS 16, *)
private struct ZendeskAttachmentView: View {
private struct ZendeskAttachmentView<Actions: View>: View {
@ObservedObject var viewModel: ZendeskAttachmentViewModel
var onRemoveTapped: () -> Void

static let previewMaxWidth: CGFloat = 120
@ViewBuilder var actions: () -> Actions

var body: some View {
Menu {
Section {
Button(role: .destructive, action: onRemoveTapped) {
Label(Strings.removeAttachment, systemImage: "trash")
}
} header: {
if case .failed(let error) = viewModel.status {
Text(error.localizedDescription)
}
}
actions() // Reloading it here because this view observes the ViewModel
} label: {
contents
}
Expand All @@ -98,7 +105,7 @@ private struct ZendeskAttachmentView: View {
Rectangle()
.foregroundStyle(Color(uiColor: .secondarySystemBackground))

viewModel.preview?
viewModel.thumbnail?
.resizable()
.aspectRatio(contentMode: .fill)

Expand All @@ -114,10 +121,14 @@ private struct ZendeskAttachmentView: View {
.foregroundStyle(.white, .red)
.font(.system(size: 22))
case .uploaded:
EmptyView()
if let duration = viewModel.export?.duration, duration > 0 {
Image(systemName: "play.circle.fill")
.font(.system(size: 20))
.foregroundStyle(Color(.secondaryLabel), Color(.secondarySystemBackground))
}
}
}
.frame(minWidth: viewModel.preview == nil ? 100 : 44, maxWidth: ZendeskAttachmentView.previewMaxWidth)
.frame(minWidth: viewModel.thumbnail == nil ? 100 : 44, maxWidth: Constants.thumbnailMaxWidth)
.frame(height: 80)
.cornerRadius(8)
}
Expand All @@ -126,15 +137,14 @@ private struct ZendeskAttachmentView: View {
final class ZendeskAttachmentViewModel: ObservableObject, Identifiable {
let id: AnyHashable

@Published private(set) var preview: Image?
@Published private(set) var thumbnail: Image?
@Published private(set) var export: MediaExport?
@Published private(set) var status: Status = .uploading

private var task: Task<Void, Never>?

var isUploaded: Bool { response != nil }

static let attachmentSizeLimit: Int64 = 8_000_000

var response: ZDKUploadResponse? {
guard case .uploaded(let response) = status else { return nil }
return response
Expand All @@ -146,15 +156,21 @@ final class ZendeskAttachmentViewModel: ObservableObject, Identifiable {
case uploaded(ZDKUploadResponse)
}

private let directory = MediaDirectory.temporary(id: UUID())

@available(iOS 16, *)
init(item: PhotosPickerItem) {
self.id = item
self.preview = preview
self.thumbnail = thumbnail
self.task = Task {
await self.process(item: item)
}
}

deinit {
try? FileManager.default.removeItem(at: directory.url)
}

func cancel() {
task?.cancel()
}
Expand All @@ -163,55 +179,47 @@ final class ZendeskAttachmentViewModel: ObservableObject, Identifiable {
@MainActor private func process(item: PhotosPickerItem) async {
status = .uploading
do {
let previewSize = CGSize(width: ZendeskAttachmentView.previewMaxWidth, height: ZendeskAttachmentView.previewMaxWidth)
let export = try await export(item)
self.export = export

let thumbnailSize = CGSize(width: Constants.thumbnailMaxWidth, height: Constants.thumbnailMaxWidth)
.scaled(by: UITraitCollection.current.displayScale)
let contentType = preferredExportContentType(for: item)
let (data, preview) = try await export(item, contentType: contentType, previewSize: previewSize)
self.preview = preview
self.thumbnail = try? await makeThumbnail(for: export, thumbnailSize: thumbnailSize)

// Checking the limit _after_ displaying the review
guard data.count < ZendeskAttachmentViewModel.attachmentSizeLimit else {
// Checking the limit _after_ displaying the preview
guard (export.fileSize ?? 0) < Constants.attachmentSizeLimit else {
throw SubmitFeedbackAttachmentError.attachmentTooLarge
}

let response = try await ZendeskUtils.sharedInstance.uploadAttachment(data, contentType: contentType.preferredMIMEType ?? "image/jpeg")
status = .uploaded(response)
status = .uploaded(try await upload(export))
} catch {
status = .failed(error)
}
}

@available(iOS 16, *)
private func preferredExportContentType(for item: PhotosPickerItem) -> UTType {
let supportedImageTypes: Set<UTType> = [UTType.png, UTType.jpeg, UTType.gif]
if let type = item.supportedContentTypes.first, supportedImageTypes.contains(type) {
return type
}
return UTType.jpeg
}

@available(iOS 16, *)
private func export(_ item: PhotosPickerItem, contentType: UTType, previewSize: CGSize) async throws -> (Data, Image) {
private func export(_ item: PhotosPickerItem) async throws -> MediaExport {
guard let rawData = try await item.loadTransferable(type: Data.self) else {
throw SubmitFeedbackAttachmentError.invalidAttachment
}
let contentType = item.supportedContentTypes.first
let itemProvider = NSItemProvider(item: rawData as NSData, typeIdentifier: contentType?.identifier)
let exporter = ItemProviderMediaExporter(provider: itemProvider)
exporter.mediaDirectoryType = directory
exporter.imageOptions = Constants.imageExportOptions
exporter.videoOptions = Constants.videoExportOptions
return try await exporter.export()
}

let exporter = MediaImageExporter(data: rawData, filename: nil, typeHint: item.supportedContentTypes.first?.identifier)
exporter.options.maximumImageSize = 1024
exporter.options.imageCompressionQuality = 0.7
exporter.mediaDirectoryType = .temporary
exporter.options.stripsGeoLocationIfNeeded = true
exporter.options.exportImageType = contentType.identifier
private func makeThumbnail(for export: MediaExport, thumbnailSize: CGSize) async throws -> Image {
let thumbnailRequest = QLThumbnailGenerator.Request(fileAt: export.url, size: thumbnailSize, scale: 1, representationTypes: .all)
let preview = try await QLThumbnailGenerator().generateBestRepresentation(for: thumbnailRequest)
return Image(uiImage: preview.uiImage)
}

let export = try await exporter.export()
private func upload(_ export: MediaExport) async throws -> ZDKUploadResponse {
let contentType = UTType(filenameExtension: export.url.pathExtension) ?? .data
let data = try Data(contentsOf: export.url)
try FileManager.default.removeItem(at: export.url)

guard let image = UIImage(data: data),
let preview = await image.byPreparingThumbnail(ofSize: previewSize) else {
throw SubmitFeedbackAttachmentError.invalidAttachment
}
return (data, Image(uiImage: preview))
return try await ZendeskUtils.sharedInstance.uploadAttachment(data, contentType: contentType.preferredMIMEType ?? "image/jpeg")
}
}

Expand All @@ -225,7 +233,7 @@ private enum SubmitFeedbackAttachmentError: Error, LocalizedError {
return NSLocalizedString("zendeskAttachmentsSection.unsupportedAttachmentErrorMessage", value: "Unsupported attachment", comment: "Managing Zendesk attachments")
case .attachmentTooLarge:
let format = NSLocalizedString("zendeskAttachmentsSection.unsupportedAttachmentErrorMessage", value: "The attachment is too large. The maximum allowed size is %@.", comment: "Managing Zendesk attachments")
return String(format: format, ByteCountFormatter().string(fromByteCount: ZendeskAttachmentViewModel.attachmentSizeLimit))
return String(format: format, ByteCountFormatter().string(fromByteCount: Constants.attachmentSizeLimit))
}
}
}
Expand All @@ -241,6 +249,28 @@ private extension View {
}
}

private enum Constants {
static let thumbnailMaxWidth: CGFloat = 120

static let attachmentSizeLimit: Int64 = 32_000_000

static let imageExportOptions: MediaImageExporter.Options = {
var options = MediaImageExporter.Options()
options.maximumImageSize = 1024
options.imageCompressionQuality = 0.7
options.stripsGeoLocationIfNeeded = true
return options
}()

static let videoExportOptions: MediaVideoExporter.Options = {
var options = MediaVideoExporter.Options()
options.exportPreset = AVAssetExportPreset1280x720
options.durationLimit = 5 * 60
options.stripsGeoLocationIfNeeded = true
return options
}()
}

private enum Strings {
static let addAttachment = NSLocalizedString("zendeskAttachmentsSection.addAttachment", value: "Add Attachments", comment: "Managing Zendesk attachments")
static let removeAttachment = NSLocalizedString("zendeskAttachmentsSection.removeAttachment", value: "Remove Attachment", comment: "Managing Zendesk attachments")
Expand Down
2 changes: 1 addition & 1 deletion WordPress/Classes/Utility/Media/MediaFileManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ enum MediaDirectory {

/// Returns the directory URL for the directory type.
///
fileprivate var url: URL {
var url: URL {
let fileManager = FileManager.default
// Get a parent directory, based on the type.
let parentDirectory: URL
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -667,5 +667,5 @@ extension MeViewController {
}

private enum Strings {
static let submitFeedback = NSLocalizedString("meMenu.submitFeedback", value: "Submit Feedback", comment: "Me tab menu items")
static let submitFeedback = NSLocalizedString("meMenu.submitFeedback", value: "Send Feedback", comment: "Me tab menu items")
}

0 comments on commit da188a6

Please sign in to comment.