From 381f3ee737ade12066987c34526869987e32162e Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Mon, 14 Oct 2024 22:57:39 -0400 Subject: [PATCH] Attachment row view --- .../AttachmentThumbnailController.swift | 2 +- .../ComposeUI/Views/AttachmentRowView.swift | 164 +++++++++++++++++- .../Views/AttachmentThumbnailView.swift | 114 ++++++++++++ .../ComposeUI/Views/AttachmentsListView.swift | 3 +- 4 files changed, 280 insertions(+), 3 deletions(-) create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentThumbnailController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentThumbnailController.swift index 015309c6..c07804a8 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentThumbnailController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentThumbnailController.swift @@ -181,7 +181,7 @@ extension EnvironmentValues { } } -private struct GIFViewWrapper: UIViewRepresentable { +struct GIFViewWrapper: UIViewRepresentable { typealias UIViewType = GIFImageView @State var controller: GIFController diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentRowView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentRowView.swift index 9539418f..7b6d967f 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentRowView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentRowView.swift @@ -6,12 +6,174 @@ // import SwiftUI +import InstanceFeatures +import Vision struct AttachmentRowView: View { @ObservedObject var attachment: DraftAttachment + @State private var isRecognizingText = false + @State private var textRecognitionError: (any Error)? + + private var thumbnailSize: CGFloat { + #if os(visionOS) + 120 + #else + 80 + #endif + } var body: some View { - Text(attachment.id.uuidString) + HStack(alignment: .center, spacing: 4) { + thumbnailView + + descriptionView + } + .alertWithData("Text Recognition Failed", data: $textRecognitionError) { _ in + Button("OK") {} + } message: { error in + Text(error.localizedDescription) + } + } + + // TODO: attachments missing descriptions feature + + private var thumbnailView: some View { + AttachmentThumbnailView(attachment: attachment) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .frame(width: thumbnailSize, height: thumbnailSize) + .contextMenu { + EditDrawingButton(attachment: attachment) + RecognizeTextButton(attachment: attachment, isRecognizingText: $isRecognizingText, error: $textRecognitionError) + DeleteButton(attachment: attachment) + } preview: { + // TODO: need to fix flash of preview changing size + AttachmentThumbnailView(attachment: attachment) + } + } + + @ViewBuilder + private var descriptionView: some View { + if isRecognizingText { + ProgressView() + .progressViewStyle(.circular) + } else { + InlineAttachmentDescriptionView(attachment: attachment, minHeight: thumbnailSize) + } + } +} + +private struct EditDrawingButton: View { + @ObservedObject var attachment: DraftAttachment + @Environment(\.composeUIConfig.presentDrawing) private var presentDrawing + + var body: some View { + if attachment.drawingData != nil { + Button(action: editDrawing) { + Label("Edit Drawing", systemImage: "hand.draw") + } + } + } + + private func editDrawing() { + if case .drawing(let drawing) = attachment.data { + presentDrawing?(drawing) { + attachment.drawing = $0 + } + } + } +} + +private struct RecognizeTextButton: View { + @ObservedObject var attachment: DraftAttachment + @Binding var isRecognizingText: Bool + @Binding var error: (any Error)? + @EnvironmentObject private var instanceFeatures: InstanceFeatures + + var body: some View { + if attachment.type == .image { + Button { + Task { + await recognizeText() + } + } label: { + Label("Recognize Text", systemImage: "doc.text.viewfinder") + } + } + } + + private func recognizeText() async { + isRecognizingText = true + defer { isRecognizingText = false } + + do { + let data = try await getAttachmentData() + let observations = try await runRecognizeTextRequest(data: data) + if let observations { + var text = "" + for observation in observations { + let result = observation.topCandidates(1).first! + text.append(result.string) + text.append("\n") + } + self.attachment.attachmentDescription = text + } + } catch let error as NSError where error.domain == VNErrorDomain && error.code == 1 { + // The perform call throws an error with code 1 if the request is cancelled, which we don't want to show an alert for. + return + } catch { + self.error = error + } + } + + private func getAttachmentData() async throws -> Data { + return try await withCheckedThrowingContinuation { continuation in + attachment.getData(features: instanceFeatures) { result in + switch result { + case .success(let (data, _)): + continuation.resume(returning: data) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + private func runRecognizeTextRequest(data: Data) async throws -> [VNRecognizedTextObservation]? { + return try await withCheckedThrowingContinuation { continuation in + let handler = VNImageRequestHandler(data: data) + let request = VNRecognizeTextRequest { request, error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: request.results as? [VNRecognizedTextObservation]) + } + } + request.recognitionLevel = .accurate + request.usesLanguageCorrection = true + DispatchQueue.global(qos: .userInitiated).async { + try? handler.perform([request]) + } + } + } +} + +private struct DeleteButton: View { + let attachment: DraftAttachment + + var body: some View { + Button(role: .destructive, action: removeAttachment) { + Label("Delete", systemImage: "trash") + } + } + + private func removeAttachment() { + let draft = attachment.draft + var array = draft.draftAttachments + guard let index = array.firstIndex(of: attachment) else { + return + } + array.remove(at: index) + draft.attachments = NSMutableOrderedSet(array: array) } } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift new file mode 100644 index 00000000..d684ff6f --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentThumbnailView.swift @@ -0,0 +1,114 @@ +// +// AttachmentThumbnailView.swift +// ComposeUI +// +// Created by Shadowfacts on 10/14/24. +// + +import SwiftUI +import TuskerComponents +import AVFoundation +import Photos + +struct AttachmentThumbnailView: View { + let attachment: DraftAttachment + let contentMode: ContentMode = .fit + @State private var mode: Mode = .empty + @EnvironmentObject private var composeController: ComposeController + + var body: some View { + switch mode { + case .empty: + Image(systemName: "photo") + .task { + await loadThumbnail() + } + case .image(let image): + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: contentMode) + case .gifController(let controller): + GIFViewWrapper(controller: controller) + } + } + + private func loadThumbnail() async { + switch attachment.data { + case .editing(_, let kind, let url): + switch kind { + case .image: + if let image = await composeController.fetchAttachment(url) { + self.mode = .image(image) + } + + case .video, .gifv: + await loadVideoThumbnail(url: url) + + case .audio, .unknown: + break + } + + case .asset(let id): + guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil).firstObject else { + return + } + let isGIF = PHAssetResource.assetResources(for: asset).contains { + $0.uniformTypeIdentifier == UTType.gif.identifier + } + if isGIF { + PHImageManager.default().requestImageDataAndOrientation(for: asset, options: nil) { data, typeIdentifier, orientation, info in + guard let data else { return } + if typeIdentifier == UTType.gif.identifier { + self.mode = .gifController(GIFController(gifData: data)) + } else if let image = UIImage(data: data) { + self.mode = .image(image) + } + } + } else { + let size = CGSize(width: 80, height: 80) + PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { image, _ in + if let image { + self.mode = .image(image) + } + } + } + + case .drawing(let drawing): + self.mode = .image(drawing.imageInLightMode(from: drawing.bounds)) + + case .file(let url, let type): + if type.conforms(to: .movie) { + await loadVideoThumbnail(url: url) + } else if let data = try? Data(contentsOf: url) { + if type == .gif { + self.mode = .gifController(GIFController(gifData: data)) + } else if type.conforms(to: .image) { + if let image = UIImage(data: data), + // using prepareThumbnail on images from PHPicker results in extremely high memory usage, + // crashing share extension. see FB12186346 + let prepared = await image.byPreparingForDisplay() { + self.mode = .image(prepared) + } + } + } + + case .none: + break + } + } + + private func loadVideoThumbnail(url: URL) async { + let asset = AVURLAsset(url: url) + let imageGenerator = AVAssetImageGenerator(asset: asset) + imageGenerator.appliesPreferredTrackTransform = true + if let (cgImage, _) = try? await imageGenerator.image(at: .zero) { + self.mode = .image(UIImage(cgImage: cgImage)) + } + } + + enum Mode { + case empty + case image(UIImage) + case gifController(GIFController) + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentsListView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentsListView.swift index d01bf3a2..c4084461 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentsListView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/AttachmentsListView.swift @@ -33,6 +33,7 @@ struct AttachmentsListView: View { // view from laying out, and leaving the intrinsic content size at zero too. .frame(minHeight: 50) .padding(.horizontal, -8) + .environmentObject(instanceFeatures) } } @@ -230,7 +231,7 @@ private final class WrappedCollectionViewCoordinator: NSObject, UICollectionView private let attachmentCell = UICollectionView.CellRegistration { cell, indexPath, item in cell.contentConfiguration = UIHostingConfiguration { - Text(item.id.uuidString) + AttachmentRowView(attachment: item) } }