diff --git a/WordPress/Classes/Utility/In-App Feedback/ZendeskAttachmentsSection.swift b/WordPress/Classes/Utility/In-App Feedback/ZendeskAttachmentsSection.swift index 7f891f789ae5..8ab36191d935 100644 --- a/WordPress/Classes/Utility/In-App Feedback/ZendeskAttachmentsSection.swift +++ b/WordPress/Classes/Utility/In-App Feedback/ZendeskAttachmentsSection.swift @@ -3,6 +3,9 @@ import Photos import PhotosUI import SupportProvidersSDK import UniformTypeIdentifiers +import QuickLook +import QuickLookThumbnailing +import AVFoundation @available(iOS 16, *) struct ZendeskAttachmentsSection: View { @@ -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) @@ -71,23 +88,13 @@ final class ZendeskAttachmentsSectionViewModel: ObservableObject { } @available(iOS 16, *) -private struct ZendeskAttachmentView: View { +private struct ZendeskAttachmentView: 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 } @@ -98,7 +105,7 @@ private struct ZendeskAttachmentView: View { Rectangle() .foregroundStyle(Color(uiColor: .secondarySystemBackground)) - viewModel.preview? + viewModel.thumbnail? .resizable() .aspectRatio(contentMode: .fill) @@ -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) } @@ -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? 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 @@ -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() } @@ -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.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") } } @@ -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)) } } } @@ -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") diff --git a/WordPress/Classes/Utility/Media/MediaFileManager.swift b/WordPress/Classes/Utility/Media/MediaFileManager.swift index a20805cc44e3..f13f75d2f459 100644 --- a/WordPress/Classes/Utility/Media/MediaFileManager.swift +++ b/WordPress/Classes/Utility/Media/MediaFileManager.swift @@ -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 diff --git a/WordPress/Classes/ViewRelated/Me/Me Main/MeViewController.swift b/WordPress/Classes/ViewRelated/Me/Me Main/MeViewController.swift index 28be492a31ec..afd4ee027b71 100644 --- a/WordPress/Classes/ViewRelated/Me/Me Main/MeViewController.swift +++ b/WordPress/Classes/ViewRelated/Me/Me Main/MeViewController.swift @@ -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") }